diff --git a/app-wear-shared/shared-impl/src/main/java/info/nightscout/shared/impl/sharedPreferences/Delegates.kt b/app-wear-shared/shared/src/main/java/info/nightscout/shared/sharedPreferences/Delegates.kt similarity index 98% rename from app-wear-shared/shared-impl/src/main/java/info/nightscout/shared/impl/sharedPreferences/Delegates.kt rename to app-wear-shared/shared/src/main/java/info/nightscout/shared/sharedPreferences/Delegates.kt index 276f77d137..24de8b5d50 100644 --- a/app-wear-shared/shared-impl/src/main/java/info/nightscout/shared/impl/sharedPreferences/Delegates.kt +++ b/app-wear-shared/shared/src/main/java/info/nightscout/shared/sharedPreferences/Delegates.kt @@ -1,4 +1,4 @@ -package info.nightscout.shared.impl.sharedPreferences +package info.nightscout.shared.sharedPreferences import info.nightscout.shared.sharedPreferences.SP import kotlin.properties.ReadWriteProperty diff --git a/app/build.gradle b/app/build.gradle index a7d725310a..bb424b1648 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -203,6 +203,7 @@ dependencies { implementation project(':database:entities') implementation project(':database:impl') implementation project(':pump:combo') + implementation project(':pump:combov2') implementation project(':pump:dana') implementation project(':pump:danars') implementation project(':pump:danar') diff --git a/app/src/main/java/info/nightscout/androidaps/activities/MyPreferenceFragment.kt b/app/src/main/java/info/nightscout/androidaps/activities/MyPreferenceFragment.kt index 7b8958f424..bccf6d878b 100644 --- a/app/src/main/java/info/nightscout/androidaps/activities/MyPreferenceFragment.kt +++ b/app/src/main/java/info/nightscout/androidaps/activities/MyPreferenceFragment.kt @@ -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.wear.WearPlugin 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.insight.LocalInsightPlugin import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicPumpPlugin @@ -89,6 +90,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang @Inject lateinit var danaRv2Plugin: DanaRv2Plugin @Inject lateinit var danaRSPlugin: DanaRSPlugin @Inject lateinit var comboPlugin: ComboPlugin + @Inject lateinit var combov2Plugin: ComboV2Plugin @Inject lateinit var insulinOrefFreePeakPlugin: InsulinOrefFreePeakPlugin @Inject lateinit var loopPlugin: LoopPlugin @Inject lateinit var localInsightPlugin: LocalInsightPlugin @@ -203,6 +205,7 @@ class MyPreferenceFragment : PreferenceFragmentCompat(), OnSharedPreferenceChang addPreferencesFromResourceIfEnabled(danaRSPlugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(localInsightPlugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(comboPlugin, rootKey, config.PUMPDRIVERS) + addPreferencesFromResourceIfEnabled(combov2Plugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(medtronicPumpPlugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(diaconnG8Plugin, rootKey, config.PUMPDRIVERS) addPreferencesFromResourceIfEnabled(eopatchPumpPlugin, rootKey, config.PUMPDRIVERS) diff --git a/app/src/main/java/info/nightscout/androidaps/di/AppComponent.kt b/app/src/main/java/info/nightscout/androidaps/di/AppComponent.kt index d31307cc56..5491a7fb64 100644 --- a/app/src/main/java/info/nightscout/androidaps/di/AppComponent.kt +++ b/app/src/main/java/info/nightscout/androidaps/di/AppComponent.kt @@ -6,6 +6,7 @@ import dagger.android.AndroidInjectionModule import dagger.android.AndroidInjector import info.nightscout.androidaps.MainApp 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.DanaModule import info.nightscout.androidaps.danar.di.DanaRModule @@ -63,6 +64,7 @@ import javax.inject.Singleton // pumps ComboModule::class, + ComboV2Module::class, DanaHistoryModule::class, DanaModule::class, DanaRModule::class, diff --git a/app/src/main/java/info/nightscout/androidaps/di/PluginsListModule.kt b/app/src/main/java/info/nightscout/androidaps/di/PluginsListModule.kt index 9c26f6c94f..85f1963696 100644 --- a/app/src/main/java/info/nightscout/androidaps/di/PluginsListModule.kt +++ b/app/src/main/java/info/nightscout/androidaps/di/PluginsListModule.kt @@ -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.iob.iobCobCalculator.IobCobCalculatorPlugin 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.insight.LocalInsightPlugin import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicPumpPlugin @@ -431,6 +432,12 @@ abstract class PluginsListModule { @IntKey(500) abstract fun bindThemeSwitcherPlugin(plugin: ThemeSwitcherPlugin): PluginBase + @Binds + @PumpDriver + @IntoMap + @IntKey(510) + abstract fun bindComboV2Plugin(plugin: ComboV2Plugin): PluginBase + @Qualifier annotation class AllConfigs diff --git a/build.gradle b/build.gradle index 118d2542c2..abb9a71129 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,9 @@ buildscript { wearable_version = '2.9.0' play_services_wearable_version = '17.1.0' play_services_location_version = '21.0.1' + + kotlinx_datetime_version = '0.3.2' + kotlinx_serialization_core_version = '1.3.2' } repositories { google() @@ -126,4 +129,4 @@ configurations { all { exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx' } -} \ No newline at end of file +} diff --git a/crowdin.yml b/crowdin.yml index 44b21b0530..df47c1634c 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -37,6 +37,8 @@ files: translation: /app-wear-shared/rx/src/main/res/values-%android_code%/strings.xml - source: /pump/combo/src/main/res/values/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 translation: /pump/dana/src/main/res/values-%android_code%/strings.xml - source: /pump/danar/src/main/res/values/strings.xml diff --git a/interfaces/src/main/java/info/nightscout/interfaces/notifications/Notification.kt b/interfaces/src/main/java/info/nightscout/interfaces/notifications/Notification.kt index 7f86ba157d..f7bfe91add 100644 --- a/interfaces/src/main/java/info/nightscout/interfaces/notifications/Notification.kt +++ b/interfaces/src/main/java/info/nightscout/interfaces/notifications/Notification.kt @@ -131,6 +131,8 @@ open class Notification { const val IDENTIFICATION_NOT_SET = 77 const val PERMISSION_BT = 78 const val EOELOW_PATCH_ALERTS = 79 + const val COMBO_PUMP_SUSPENDED = 80 + const val COMBO_UNKNOWN_TBR = 81 const val USER_MESSAGE = 1000 diff --git a/pump/combov2/build.gradle b/pump/combov2/build.gradle new file mode 100644 index 0000000000..f608ae71e6 --- /dev/null +++ b/pump/combov2/build.gradle @@ -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 + } +} diff --git a/pump/combov2/comboctl-changes.txt b/pump/combov2/comboctl-changes.txt new file mode 100644 index 0000000000..728e9ec14c --- /dev/null +++ b/pump/combov2/comboctl-changes.txt @@ -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. diff --git a/pump/combov2/comboctl/build.gradle b/pump/combov2/comboctl/build.gradle new file mode 100644 index 0000000000..8f44e158da --- /dev/null +++ b/pump/combov2/comboctl/build.gradle @@ -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" +} diff --git a/pump/combov2/comboctl/src/androidMain/AndroidManifest.xml b/pump/combov2/comboctl/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000000..bc681e6eb5 --- /dev/null +++ b/pump/combov2/comboctl/src/androidMain/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidBluetoothDevice.kt b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidBluetoothDevice.kt new file mode 100644 index 0000000000..8f954ec0cb --- /dev/null +++ b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidBluetoothDevice.kt @@ -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) { + // 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 { + // 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" } + } +} diff --git a/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidBluetoothInterface.kt b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidBluetoothInterface.kt new file mode 100644 index 0000000000..92e78df629 --- /dev/null +++ b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidBluetoothInterface.kt @@ -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() + + // 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() + + // 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 = + 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.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.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.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() + } + } + } +} diff --git a/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidLoggerBackend.kt b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidLoggerBackend.kt new file mode 100644 index 0000000000..3d75ce9ffe --- /dev/null +++ b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidLoggerBackend.kt @@ -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) + } + } +} diff --git a/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/BluetoothPermissionChecks.kt b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/BluetoothPermissionChecks.kt new file mode 100644 index 0000000000..2bfbb81b68 --- /dev/null +++ b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/BluetoothPermissionChecks.kt @@ -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) : + BluetoothPermissionException("Missing Bluetooth permissions: ${missingPermissions.joinToString(", ")}") + +internal fun checkForConnectPermission(androidContext: Context, block: () -> T) = + checkForPermissions(androidContext, listOf(bluetoothConnectPermission), block) + +internal fun checkForPermission(androidContext: Context, permission: String, block: () -> T) = + checkForPermissions(androidContext, listOf(permission), block) + +internal fun checkForPermissions(androidContext: Context, permissions: List, 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 + } +} diff --git a/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/Constants.kt b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/Constants.kt new file mode 100644 index 0000000000..d9bea9e480 --- /dev/null +++ b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/Constants.kt @@ -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")!! +} diff --git a/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/utils/Retries.kt b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/utils/Retries.kt new file mode 100644 index 0000000000..569834c593 --- /dev/null +++ b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/utils/Retries.kt @@ -0,0 +1,24 @@ +package info.nightscout.comboctl.utils + +fun 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) +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ApplicationLayer.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ApplicationLayer.kt new file mode 100644 index 0000000000..6077721da9 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ApplicationLayer.kt @@ -0,0 +1,1739 @@ +package info.nightscout.comboctl.base + +import kotlinx.datetime.LocalDateTime + +private val logger = Logger.get("ApplicationLayer") + +/** + * This object contains types and constants related to the Combo application layer. + * The types include classes (exceptions, packet ...) and enums (available commands ...) + * This also contains functions for creating and parsing application layer packets. + * These packets are wrapped in transport layer DATA packets; that is, the application + * layer packet data is stored as the payload of the DATA packet. + * + * Unlike [TransportLayer], this has no IO class. + */ +object ApplicationLayer { + // Application layer packet structure (excluding the additional transport layer packet metadata): + // + // 1. 4 bits : Application layer major version (always set to 0x01) + // 2. 4 bits : Application layer minor version (always set to 0x00) + // 3. 8 bits : Service ID; can be one of these values: + // 0x00 : control service ID + // 0x48 : RT mode service ID + // 0xB7 : command mode service ID + // 4. 16 bits : Command ID, stored as a 16-bit little endian integer + // 5. n bytes : Payload + + // 1 byte with major & minor version + // 1 byte with service ID + // 2 bytes with command ID + const val PACKET_HEADER_SIZE = 1 + 1 + 2 + + const val VERSION_BYTE_OFFSET = 0 + const val SERVICE_ID_BYTE_OFFSET = 1 + const val COMMAND_ID_BYTE_OFFSET = 2 + const val PAYLOAD_BYTES_OFFSET = 4 + + /** + * Maximum allowed size for application layer packet payloads, in bytes. + */ + const val MAX_VALID_PAYLOAD_SIZE = 65535 - PACKET_HEADER_SIZE + + /** + * Base class for application layer exceptions. + * + * @param message The detail message. + */ + open class ExceptionBase(message: String) : ComboException(message) + + /** + * Exception thrown when an application layer packet arrives with an invalid service ID. + * + * @property tpLayerPacket Underlying transport layer DATA packet containing the application layer packet data. + * @property serviceID The invalid service ID. + * @property payload The application packet's payload. + */ + class InvalidServiceIDException( + val tpLayerPacket: TransportLayer.Packet, + val serviceID: Int, + val payload: List + ) : ExceptionBase("Invalid/unknown application layer packet service ID 0x${serviceID.toString(16)}") + + /** + * Exception thrown when an application layer packet arrives with an invalid application layer command ID. + * + * @property tpLayerPacket Underlying transport layer DATA packet containing the application layer packet data. + * @property serviceID Service ID from the application layer packet. + * @property commandID The invalid application layer command ID. + * @property payload The application packet's payload. + */ + class InvalidCommandIDException( + val tpLayerPacket: TransportLayer.Packet, + val serviceID: ServiceID, + val commandID: Int, + val payload: List + ) : ExceptionBase( + "Invalid/unknown application layer packet command ID " + + "0x${commandID.toString(16)} (service ID: ${serviceID.name})" + ) + + /** + * Exception thrown when a different application layer packet was expected than the one that arrived. + * + * More precisely, the arrived packet's command is not the one that was expected. + * + * @property appLayerPacket Application layer packet that arrived. + * @property expectedCommand The command that was expected in the packet. + */ + class IncorrectPacketException( + val appLayerPacket: Packet, + val expectedCommand: Command + ) : ExceptionBase( + "Incorrect packet: expected ${expectedCommand.name} " + + "packet, got ${appLayerPacket.command.name} one" + ) + + /** + * Exception thrown when the combo sends a CTRL_SERVICE_ERROR packet. + * + * These packets notify about errors in the communication between client and Combo + * at the application layer. + * + * @property appLayerPacket Application layer packet that arrived. + * @property serviceError The service error information from the packet. + */ + class ServiceErrorException( + val appLayerPacket: Packet, + val serviceError: CTRLServiceError + ) : ExceptionBase( + "Service error reported by Combo: $serviceError" + ) + + /** + * Exception thrown when something is wrong with an application layer packet's payload. + * + * @property appLayerPacket Application layer packet with the invalid payload. + * @property message Detail message. + */ + class InvalidPayloadException( + val appLayerPacket: Packet, + message: String + ) : ExceptionBase(message) + + /** + * Exception thrown when something a packet's payload data is considered corrupted. + * + * This is distinct from [InvalidPayloadException] in that the former is more concerned + * about parameters like the payload size (example: "expected 15 bytes payload, got 7 bytes"), + * while this exception is thrown when for example a CRC integrity check indicates that + * the payload bytes themselves are incorrect. + * + * @property appLayerPacket Application layer packet with the corrupted payload. + * @property message Detail message. + */ + class PayloadDataCorruptionException( + val appLayerPacket: Packet, + message: String + ) : ExceptionBase(message) + + /** + * Exception thrown when during an attempt to retrieve history data said data never seems to end. + * + * Normally, there will eventually be a packet that indicates that the history + * has been fully received. If no such packet arrives, then something is wrong. + * + * @property message Detail message. + */ + class InfiniteHistoryDataException( + message: String + ) : ExceptionBase(message) + + /** + * Exception thrown when an application layer packet is received with an error code that indicates an error. + * + * All application layer packets that are transmitted to the client via reliable + * transport layer packet have a 16-bit error code in the first 2 bytes of their + * payloads. If this error code's value is 0, there's no error. Otherwise, an + * error occurred. These are not recoverable, so this exception is thrown which + * causes the packet receiver to fail. + * + * @property appLayerPacket Application layer packet with the nonzero error code. + * @property errorCode Parsed error code. + */ + class ErrorCodeException( + val appLayerPacket: Packet, + val errorCode: ErrorCode + ) : ExceptionBase("received error code $errorCode in packet $appLayerPacket") + + /** + * Valid application layer commands. + * + * An application layer command is a combination of a service ID, a command ID, + * and a flag whether or not the command is to be sent with the underlying + * DATA transport layer packet's reliability flag set or unset. The former + * two already uniquely identify the command; the "reliable" flag is additional + * information. + */ + enum class Command(val serviceID: ServiceID, val commandID: Int, val reliable: Boolean) { + CTRL_CONNECT(ServiceID.CONTROL, 0x9055, true), + CTRL_CONNECT_RESPONSE(ServiceID.CONTROL, 0xA055, true), + CTRL_GET_SERVICE_VERSION(ServiceID.CONTROL, 0x9065, true), + CTRL_GET_SERVICE_VERSION_RESPONSE(ServiceID.CONTROL, 0xA065, true), + CTRL_BIND(ServiceID.CONTROL, 0x9095, true), + CTRL_BIND_RESPONSE(ServiceID.CONTROL, 0xA095, true), + CTRL_DISCONNECT(ServiceID.CONTROL, 0x005A, true), + CTRL_ACTIVATE_SERVICE(ServiceID.CONTROL, 0x9066, true), + CTRL_ACTIVATE_SERVICE_RESPONSE(ServiceID.CONTROL, 0xA066, true), + CTRL_DEACTIVATE_SERVICE(ServiceID.CONTROL, 0x9069, true), + CTRL_DEACTIVATE_SERVICE_RESPONSE(ServiceID.CONTROL, 0xA069, true), + CTRL_DEACTIVATE_ALL_SERVICES(ServiceID.CONTROL, 0x906A, true), + CTRL_DEACTIVATE_ALL_SERVICES_RESPONSE(ServiceID.CONTROL, 0xA06A, true), + CTRL_SERVICE_ERROR(ServiceID.CONTROL, 0x00AA, true), + + CMD_PING(ServiceID.COMMAND_MODE, 0x9AAA, true), + CMD_PING_RESPONSE(ServiceID.COMMAND_MODE, 0xAAAA, true), + CMD_READ_DATE_TIME(ServiceID.COMMAND_MODE, 0x9AA6, true), + CMD_READ_DATE_TIME_RESPONSE(ServiceID.COMMAND_MODE, 0xAAA6, true), + CMD_READ_PUMP_STATUS(ServiceID.COMMAND_MODE, 0x9A9A, true), + CMD_READ_PUMP_STATUS_RESPONSE(ServiceID.COMMAND_MODE, 0xAA9A, true), + CMD_READ_ERROR_WARNING_STATUS(ServiceID.COMMAND_MODE, 0x9AA5, true), + CMD_READ_ERROR_WARNING_STATUS_RESPONSE(ServiceID.COMMAND_MODE, 0xAAA5, true), + CMD_READ_HISTORY_BLOCK(ServiceID.COMMAND_MODE, 0x9996, true), + CMD_READ_HISTORY_BLOCK_RESPONSE(ServiceID.COMMAND_MODE, 0xA996, true), + CMD_CONFIRM_HISTORY_BLOCK(ServiceID.COMMAND_MODE, 0x9999, true), + CMD_CONFIRM_HISTORY_BLOCK_RESPONSE(ServiceID.COMMAND_MODE, 0xA999, true), + CMD_GET_BOLUS_STATUS(ServiceID.COMMAND_MODE, 0x966A, true), + CMD_GET_BOLUS_STATUS_RESPONSE(ServiceID.COMMAND_MODE, 0xA66A, true), + CMD_DELIVER_BOLUS(ServiceID.COMMAND_MODE, 0x9669, true), + CMD_DELIVER_BOLUS_RESPONSE(ServiceID.COMMAND_MODE, 0xA669, true), + CMD_CANCEL_BOLUS(ServiceID.COMMAND_MODE, 0x9695, true), + CMD_CANCEL_BOLUS_RESPONSE(ServiceID.COMMAND_MODE, 0xA695, true), + + RT_BUTTON_STATUS(ServiceID.RT_MODE, 0x0565, false), + RT_KEEP_ALIVE(ServiceID.RT_MODE, 0x0566, false), + RT_BUTTON_CONFIRMATION(ServiceID.RT_MODE, 0x0556, false), + RT_DISPLAY(ServiceID.RT_MODE, 0x0555, false), + RT_AUDIO(ServiceID.RT_MODE, 0x0559, false), + RT_VIBRATION(ServiceID.RT_MODE, 0x055A, false), + RT_PAUSE(ServiceID.RT_MODE, 0x0569, false), + RT_RELEASE(ServiceID.RT_MODE, 0x056A, false); + + companion object { + private val values = Command.values() + + /** + * Returns the command that has a matching service ID and command ID. + * + * @return Command, or null if no matching command exists. + */ + fun fromIDs(serviceID: ServiceID, commandID: Int) = values.firstOrNull { + (it.serviceID == serviceID) && (it.commandID == commandID) + } + } + } + + /** + * Valid application layer command service IDs. + */ + enum class ServiceID(val id: Int) { + CONTROL(0x00), + RT_MODE(0x48), + COMMAND_MODE(0xB7); + + companion object { + private val values = ServiceID.values() + /** + * Converts an int to a service ID. + * + * @return ServiceID, or null if the int is not a valid ID. + */ + fun fromInt(value: Int) = values.firstOrNull { it.id == value } + } + } + + /** + * Class for error codes contained in reliable application layer packets coming from the pump. + * + * All application layer packets that are transmitted to the client via reliable + * transport layer packet have a 16-bit error code in the first 2 bytes of their + * payloads. This class contains that error code. The [ErrorCode.Known.Code] enum + * contains all currently known error codes. [ErrorCode.Unknown] is used in case + * the error code value is not one of the known ones. The toString functions of + * both [ErrorCode.Known] and [ErrorCode.Unknown] are overridden to provide better + * descriptions of their contents. + * + * The [ErrorCode.fromInt] function is used for converting an integer value to + * an ErrorCode instance. Said integer comes from the reliable packets. + */ + sealed class ErrorCode { + data class Known(val code: Code) : ErrorCode() { + override fun toString(): String = "error code \"${code.description}\"" + + enum class Category { + GENERAL, + REMOTE_TERMINAL_MODE, + COMMAND_MODE + } + + enum class Code(val value: Int, val category: Category, val description: String) { + NO_ERROR(0x0000, Category.GENERAL, "No error"), + + UNKNOWN_SERVICE_ID(0xF003, Category.GENERAL, "Unknown service ID"), + INCOMPATIBLE_AL_PACKET_VERSION(0xF005, Category.GENERAL, "Incompatible application layer packet version"), + INVALID_PAYLOAD_LENGTH(0xF006, Category.GENERAL, "Invalid payload length"), + NOT_CONNECTED(0xF056, Category.GENERAL, "Application layer not connected"), + INCOMPATIBLE_SERVICE_VERSION(0xF059, Category.GENERAL, "Incompatible service version"), + REQUEST_WITH_UNKNOWN_SERVICE_ID(0xF05A, Category.GENERAL, + "Version, activate, deactivate request with unknown service ID"), + SERVICE_ACTIVATION_NOT_ALLOWED(0xF05C, Category.GENERAL, "Service activation not allowed"), + COMMAND_NOT_ALLOWED(0xF05F, Category.GENERAL, "Command not allowed (wrong mode)"), + + RT_PAYLOAD_WRONG_LENGTH(0xF503, Category.REMOTE_TERMINAL_MODE, "RT payload wrong length"), + RT_DISPLAY_INCORRECT_INDEX(0xF505, Category.REMOTE_TERMINAL_MODE, + "RT display with incorrect row index, update, or display index"), + RT_DISPLAY_TIMEOUT(0xF506, Category.REMOTE_TERMINAL_MODE, "RT display timeout"), + RT_UNKNOWN_AUDIO_SEQUENCE(0xF509, Category.REMOTE_TERMINAL_MODE, "RT unknown audio sequence"), + RT_UNKNOWN_VIBRATION_SEQUENCE(0xF50A, Category.REMOTE_TERMINAL_MODE, "RT unknown vibration sequence"), + RT_INCORRECT_SEQUENCE_NUMBER(0xF50C, Category.REMOTE_TERMINAL_MODE, "RT command has incorrect sequence number"), + RT_ALIVE_TIMEOUT_EXPIRED(0xF533, Category.REMOTE_TERMINAL_MODE, "RT alive timeout expired"), + + CMD_VALUES_NOT_WITHIN_THRESHOLD(0xF605, Category.COMMAND_MODE, "CMD values not within threshold"), + CMD_WRONG_BOLUS_TYPE(0xF606, Category.COMMAND_MODE, "CMD wrong bolus type"), + CMD_BOLUS_NOT_DELIVERING(0xF60A, Category.COMMAND_MODE, "CMD bolus not delivering"), + CMD_HISTORY_READ_EEPROM_ERROR(0xF60C, Category.COMMAND_MODE, "CMD history read EEPROM error"), + CMD_HISTORY_FRAM_NOT_ACCESSIBLE(0xF633, Category.COMMAND_MODE, "CMD history confirm FRAM not readable or writeable"), + CMD_UNKNOWN_BOLUS_TYPE(0xF635, Category.COMMAND_MODE, "CMD unknown bolus type"), + CMD_BOLUS_CURRENTLY_UNAVAILABLE(0xF636, Category.COMMAND_MODE, "CMD bolus is not available at the moment"), + CMD_INCORRECT_CRC_VALUE(0xF639, Category.COMMAND_MODE, "CMD incorrect CRC value"), + CMD_CH1_CH2_VALUES_INCONSISTENT(0xF63A, Category.COMMAND_MODE, "CMD ch1 and ch2 values inconsistent"), + CMD_INTERNAL_PUMP_ERROR(0xF63C, Category.COMMAND_MODE, "CMD pump has internal error (RAM values changed)"); + } + } + data class Unknown(val code: Int) : ErrorCode() { + override fun toString(): String = "unknown error code ${code.toHexString(4, true)}" + } + + companion object { + private val knownCodes = Known.Code.values() + + fun fromInt(value: Int): ErrorCode { + val foundCode = knownCodes.firstOrNull { (it.value == value) } + return if (foundCode != null) + Known(foundCode) + else + Unknown(value) + } + } + } + + /** + * Error information from CTRL_SERVICE_ERROR packets. + * + * The service and command ID are kept as integer on purpose, since + * it is not known if all possible values are known, so directly + * having enum types here would not allow for representing unknown + * values properly. + * + * @property errorCode Error code specifying the error. + * @property serviceIDValue Integer with the value of the + * service ID of the command that caused the error. + * @property commandIDValue Integer with the value of the + * command ID of the command that caused the error. + */ + data class CTRLServiceError( + val errorCode: ErrorCode, + val serviceIDValue: Int, + val commandIDValue: Int + ) { + override fun toString(): String { + var command: Command? = null + + val serviceID = ServiceID.fromInt(serviceIDValue) + if (serviceID != null) + command = Command.fromIDs(serviceID, commandIDValue) + + val commandStr = + if (command != null) + "command \"${command.name}\"" + else + "service ID 0x${serviceIDValue.toString(16)} command ID 0x${commandIDValue.toString(16)}" + + return "$errorCode $commandStr" + } + } + + /** + * Possible status the pump can be in. + */ + enum class CMDPumpStatus(val str: String) { + STOPPED("STOPPED"), + RUNNING("RUNNING"); + + override fun toString() = str + } + + data class CMDErrorWarningStatus(val errorOccurred: Boolean, val warningOccurred: Boolean) + + /** + * Command mode history event details. + * + * IMPORTANT: Bolus amounts are given in 0.1 IU units, + * so for example, "57" means 5.7 IU. + */ + sealed class CMDHistoryEventDetail(val isBolusDetail: Boolean) { + data class QuickBolusRequested(val bolusAmount: Int) : CMDHistoryEventDetail(isBolusDetail = true) + data class QuickBolusInfused(val bolusAmount: Int) : CMDHistoryEventDetail(isBolusDetail = true) + data class StandardBolusRequested( + val bolusAmount: Int, + val manual: Boolean + ) : CMDHistoryEventDetail(isBolusDetail = true) + data class StandardBolusInfused( + val bolusAmount: Int, + val manual: Boolean + ) : CMDHistoryEventDetail(isBolusDetail = true) + data class ExtendedBolusStarted( + val totalBolusAmount: Int, + val totalDurationMinutes: Int + ) : CMDHistoryEventDetail(isBolusDetail = true) + data class ExtendedBolusEnded( + val totalBolusAmount: Int, + val totalDurationMinutes: Int + ) : CMDHistoryEventDetail(isBolusDetail = true) + data class MultiwaveBolusStarted( + val totalBolusAmount: Int, + val immediateBolusAmount: Int, + val totalDurationMinutes: Int + ) : CMDHistoryEventDetail(isBolusDetail = true) + data class MultiwaveBolusEnded( + val totalBolusAmount: Int, + val immediateBolusAmount: Int, + val totalDurationMinutes: Int + ) : CMDHistoryEventDetail(isBolusDetail = true) + data class NewDateTimeSet(val dateTime: LocalDateTime) : CMDHistoryEventDetail(isBolusDetail = false) + } + + /** + * Information about an event in a command mode history block. + * + * "Quick bolus of 3.7 IU infused at 2020-03-11 11:55:23" is one example + * of the information events provide. Each event contains a timestamp + * and event specific details. + * + * Each event has an associated counter value. The way it is currently + * understood is that these are the values of a unique internal event + * counter at the time the event occurred, making this a de-facto ID. + * + * @property timestamp Timestamp of when the event occurred. + * @property eventCounter Counter value for this event. + * @property detail Event specific details (see [CMDHistoryEventDetail]). + */ + data class CMDHistoryEvent( + val timestamp: LocalDateTime, + val eventCounter: Long, + val detail: CMDHistoryEventDetail + ) { + 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 CMDHistoryEvent + + if (timestamp != other.timestamp) + return false + + if (eventCounter != other.eventCounter) + return false + + if (detail != other.detail) + return false + + return true + } + + override fun hashCode(): Int { + var result = timestamp.hashCode() + result = 31 * result + eventCounter.hashCode() + result = 31 * result + detail.hashCode() + return result + } + } + + /** + * A block of command mode history events. + * + * In command mode, history events are communicated in blocks. Each block + * consists of a list of "events", for example "quick bolus of 0.5 infused". + * Each event has a timestamp and event specific details. In addition, the + * block contains extra information about the other available events. + * + * To get all available events, the user has to send multiple history block + * requests according to that extra information. If moreEventsAvailable is + * true, then there are more history blocks that can be retrieved. Otherwise, + * this is the last block. + * + * A block is retrieved with the CMD_READ_HISTORY_BLOCK command, and arrives + * as the CMD_READ_HISTORY_BLOCK_RESPONSE command. The former is generated + * using [createCMDReadHistoryBlockPacket], the latter is parsed using + * [parseCMDReadHistoryBlockResponsePacket]. The parse function throws an + * exception if its integrity checks discover that the block seems corrupted. + * In such a case, the block can be requested again simply by sending the + * CMD_READ_HISTORY_BLOCK again. If the block is OK, it is confirmed by + * sending CMD_CONFIRM_HISTORY_BLOCK. This will inform the Combo that the + * user is done with that block. Afterwards, a CMD_READ_HISTORY_BLOCK + * command sent to the Combo will result in the next block being returned. + * + * In pseudo code: + * + * ``` + * while (true) { + * sendPacketToCombo(createCMDReadHistoryBlockPacket()) + * packet = waitForPacketFromCombo(CMD_READ_HISTORY_BLOCK_RESPONSE) + * + * try { + * historyBlock = parseCMDReadHistoryBlockResponsePacket(packet) + * } catch (exception) { + * continue + * } + * + * processHistoryBlock(historyBlock) + * + * sendPacketToCombo(createCMDConfirmHistoryBlockPacket()) + * waitForPacketFromCombo(CMD_CONFIRM_HISTORY_BLOCK_RESPONSE) // actual packet data is not needed here + * + * if (!historyBlock.moreEventsAvailable) + * break + * } + * ``` + * + * @property numRemainingEvents How many events remain available. This + * includes the number of events in this block. This means that + * numRemainingEvents is <= events.size in the last block. + * @property moreEventsAvailable true if there are more events available + * in other blocks, false if this is the last block. + * @property historyGap If the history FIFO buffer's capacity was exceeded + * and the oldest history events were overwritten as a result. + * @events List of history events in this block. + */ + data class CMDHistoryBlock( + val numRemainingEvents: Int, + val moreEventsAvailable: Boolean, + val historyGap: Boolean, + val events: List + ) + + /** + * Possible bolus types used in COMMAND mode commands. + */ + enum class CMDBolusType(val id: Int) { + STANDARD(0x47), + MULTI_WAVE(0xB7); + + companion object { + private val values = CMDBolusType.values() + fun fromInt(value: Int) = values.firstOrNull { it.id == value } + } + } + + /** + * Possible states of an ongoing bolus (or NOT_DELIVERING if there's no bolus ongoing). + */ + enum class CMDBolusDeliveryState(val id: Int) { + NOT_DELIVERING(0x55), + DELIVERING(0x66), + DELIVERED(0x99), + CANCELLED_BY_USER(0xA9), + ABORTED_DUE_TO_ERROR(0xAA); + + companion object { + private val values = CMDBolusDeliveryState.values() + fun fromInt(value: Int) = values.firstOrNull { it.id == value } + } + } + + /** + * Information about an ongoing bolus. + * + * If bolusType is set to [CMDBolusDeliveryState.NOT_DELIVERING], + * then the other fields are meaningless. + * + * @property bolusType Type of the bolus (standard / multi-wave). + * @property deliveryState Type of the current bolus delivery. + * @proeperty remainingAmount Remaining bolus amount to administer. + * Note that this is given in 0.1 IU units, so for example, + * "57" means 5.7 IU. + */ + data class CMDBolusDeliveryStatus( + val bolusType: CMDBolusType, + val deliveryState: CMDBolusDeliveryState, + val remainingAmount: Int + ) + + /** + * Valid button codes that an RT_BUTTON_STATUS packet can contain in its payload. + * These can be bitwise OR combined to implement combined button presses. + */ + enum class RTButton(val id: Int, val str: String) { + UP(0x30, "UP"), + DOWN(0xC0, "DOWN"), + MENU(0x03, "MENU"), + CHECK(0x0C, "CHECK"), + NO_BUTTON(0x00, "NO_BUTTON") + } + + /** + * Valid display update reasons that an RT_DISPLAY packet can contain in its payload. + */ + enum class RTDisplayUpdateReason(val id: Int) { + UPDATED_BY_COMBO(0x48), + UPDATED_BY_CLIENT(0xB7); + + companion object { + private val values = RTDisplayUpdateReason.values() + /** + * Converts an int to an RTDisplayUpdateReason. + * + * @return RTDisplayUpdateReason, or null if the int is not a valid reason ID. + */ + fun fromInt(value: Int) = values.firstOrNull { it.id == value } + } + } + + /** + * Data class containing the fields of an RT_DISPLAY packet's payload. + */ + data class RTDisplayPayload( + val currentRTSequence: Int, + val reason: RTDisplayUpdateReason, + val index: Int, + val row: Int, + val rowBytes: List + ) + + /** + * Extracts the [Command] out of a transport layer DATA packet. + * + * Transport layer DATA packets contain application layer packets as + * their payload. In some cases, it is necessary to "peek" into that + * payload to see what application layer command this contains. This + * function serves that purpose. + * + * @param tpLayerPacket Transport layer packet to extract the [Command] from. + * @return The extracted [Command]. + * @throws TransportLayer.IncorrectPacketException if [tpLayerPacket] + * is not a DATA packet. + */ + fun extractAppLayerPacketCommand( + tpLayerPacket: TransportLayer.Packet + ): Command { + if (tpLayerPacket.command != TransportLayer.Command.DATA) { + throw TransportLayer.IncorrectPacketException( + tpLayerPacket, + TransportLayer.Command.DATA + ) + } + + val serviceIDInt = tpLayerPacket.payload[SERVICE_ID_BYTE_OFFSET].toPosInt() + val serviceID = ServiceID.fromInt(serviceIDInt) + ?: throw InvalidServiceIDException( + tpLayerPacket, + serviceIDInt, + ArrayList(tpLayerPacket.payload.subList(PAYLOAD_BYTES_OFFSET, tpLayerPacket.payload.size)) + ) + + val commandID = (tpLayerPacket.payload[COMMAND_ID_BYTE_OFFSET + 0].toPosInt() shl 0) or + (tpLayerPacket.payload[COMMAND_ID_BYTE_OFFSET + 1].toPosInt() shl 8) + + return Command.fromIDs(serviceID, commandID) + ?: throw InvalidCommandIDException( + tpLayerPacket, + serviceID, + commandID, + ArrayList(tpLayerPacket.payload.subList(PAYLOAD_BYTES_OFFSET, tpLayerPacket.payload.size)) + ) + } + + /** + * Class containing data of a Combo application layer packet. + * + * Just like the transport layer, the application layer also uses packets as the + * basic unit. Each application layer packet is contained in a transport layer + * DATA packet and contains a small header and a payload. It is easy to confuse + * its payload with the payload of the transport layer DATA packet, since this + * packet's data _is_ the payload of the DATA transport layer packet. In other + * words, within the payload of the DATA transport layer packet is _another_ + * header (the application layer packet header), and after that, the actual + * application layer packet comes. + * + * Unlike transport layer packet data, application layer packet data does not + * include any CRC or MAC authentication metadata (since the underlying DATA + * packet already provides that). + * + * Also note that the application layer packet header contains a version byte. + * It is identical in structure to the version byte in the transport layer packet + * header, but is something entirely separate. + * + * See "Application layer packet structure" in combo-comm-spec.adoc for details. + * + * Since these packets are stored in the payload of transport layer DATA packets, + * the transport layer DATA packet's reliability and sequence bits do need to + * be addressed. This is done by looking up the "reliable" boolean in the command + * enum value. For each valid command, there is one such boolean. It determines + * whether the reliability bit of the DATA TL packet will be set or cleared. + * + * NOTE: Prefer using [toAppLayerPacket] instead of directly constructing + * an instance of this class on packets that were received from the Combo. + * That extension function runs additional checks on the packet. + * + * @property command The command of this packet. This is a combination of a + * service ID and a command ID, which together uniquely identify + * the command. + * @property version Byte containing version numbers. The upper 4 bit contain the + * major, the lower 4 bit the minor version number. + * In all observed packets, this was set to 0x10. + * @property payload The application layer packet payload. + * @throws IllegalArgumentException if the payload size exceeds + * [MAX_VALID_PAYLOAD_SIZE]. + */ + data class Packet( + val command: Command, + val version: Byte = 0x10, + var payload: ArrayList = ArrayList(0) + ) { + init { + if (payload.size > MAX_VALID_PAYLOAD_SIZE) { + throw IllegalArgumentException( + "Payload size ${payload.size} exceeds allowed maximum of $MAX_VALID_PAYLOAD_SIZE bytes" + ) + } + } + + /** + * Creates an application layer packet out of a transport layer DATA packet. + * + * @param tpLayerPacket The transport layer DATA packet. + * @throws IncorrectPacketException if the given packet is not a DATA packet. + */ + constructor(tpLayerPacket: TransportLayer.Packet) : this( + command = extractAppLayerPacketCommand(tpLayerPacket), + version = tpLayerPacket.payload[VERSION_BYTE_OFFSET], + payload = ArrayList(tpLayerPacket.payload.subList(PAYLOAD_BYTES_OFFSET, tpLayerPacket.payload.size)) + ) { + } + + /** + * Produces transport layer DATA packet info containing this application layer + * packet's data as its payload. + * + * @return Transport layer DATA packet. + */ + fun toTransportLayerPacketInfo(): TransportLayer.OutgoingPacketInfo { + val appLayerPacketPayload = ArrayList(PACKET_HEADER_SIZE + payload.size) + appLayerPacketPayload.add(version) + appLayerPacketPayload.add(command.serviceID.id.toByte()) + appLayerPacketPayload.add(((command.commandID shr 0) and 0xFF).toByte()) + appLayerPacketPayload.add(((command.commandID shr 8) and 0xFF).toByte()) + appLayerPacketPayload.addAll(payload) + + return TransportLayer.createDataPacketInfo( + command.reliable, + appLayerPacketPayload + ) + } + + override fun toString(): String { + return "version: ${version.toHexString(2)}" + + " service ID: ${command.serviceID}" + + " command: $command" + + " payload: ${payload.size} byte(s): ${payload.toHexString()}" + } + } + + /** + * Parses the first 2 bytes of a packet's payload. + * + * This only works if the packet was sent by the Combo as a reliable + * transport layer packet that contains an application layer packet. + * Only then will that encapsulated application layer's payload have + * a 16-bit error code in its first 2 bytes. + * + * @param payload Payload to parse. + */ + fun parseErrorCode(payload: List) = + ErrorCode.fromInt((payload[0].toPosInt() shl 0) or (payload[1].toPosInt() shl 8)) + + // NOTE: Some of the CTRL and CMD packet parse functions below do + // not touch the first 2 bytes of the payload. This is because these + // first 2 bytes contain a 16-bit error code, and that error code is + // already dealt with in the checkAndParseTransportLayerDataPacket() + // function. + + /** + * Creates a CTRL_CONNECT packet. + * + * This initiates a connection at the application layer. The transport + * layer must have been connected first with the transport layer's + * REQUEST_REGULAR_CONNECTION command. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCTRLConnectPacket(): Packet { + val serialNumber = Constants.APPLICATION_LAYER_CONNECT_SERIAL_NUMBER + val payload = byteArrayListOfInts( + (serialNumber shr 0) and 0xFF, + (serialNumber shr 8) and 0xFF, + (serialNumber shr 16) and 0xFF, + (serialNumber shr 24) and 0xFF + ) + return Packet( + command = Command.CTRL_CONNECT, + payload = payload + ) + } + + /** + * Creates a CTRL_GET_SERVICE_VERSION packet. + * + * This is used during the pairing process. It is not needed in + * regular connections. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCTRLGetServiceVersionPacket(serviceID: ServiceID) = Packet( + command = Command.CTRL_GET_SERVICE_VERSION, + payload = byteArrayListOfInts(serviceID.id) + ) + + /** + * Creates a CTRL_BIND packet. + * + * This is used during the pairing process. It is not needed in + * regular connections. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCTRLBindPacket() = Packet( + // TODO: See the spec for this command. It is currently + // unclear why the payload has to be 0x48. + command = Command.CTRL_BIND, + payload = byteArrayListOfInts(0x48) + ) + + /** + * Creates a CTRL_DISCONNECT packet. + * + * This terminates the connection at the application layer. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCTRLDisconnectPacket() = Packet( + // TODO: See the spec for this command. It is currently + // unclear why the payload should be 0x6003, and why + // Ruffy sets this to 0x0003 instead. But since we know + // that Ruffy works, we currently pick 0x0003. + // Also, this payload is actually an error code. + // Should it just be 0x0000 instead? + command = Command.CTRL_DISCONNECT, + payload = byteArrayListOfInts(0x03, 0x00) + ) + + /** + * Creates a CTRL_ACTIVATE_SERVICE packet. + * + * This activates the RT or command mode (depending on the argument). + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCTRLActivateServicePacket(serviceID: ServiceID) = Packet( + command = Command.CTRL_ACTIVATE_SERVICE, + payload = byteArrayListOfInts(serviceID.id, 1, 0) + ) + + /** + * Creates a CTRL_DEACTIVATE_SERVICE packet. + * + * This deactivates the active service with the given ID. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @param serviceID ID of the service to deactivate. + * @return The produced packet. + */ + fun createCTRLDeactivateServicePacket(serviceID: ServiceID) = Packet( + command = Command.CTRL_DEACTIVATE_SERVICE, + payload = byteArrayListOfInts(serviceID.id) + ) + + /** + * Creates a CTRL_DEACTIVATE_ALL_SERVICES packet. + * + * This deactivates any currently active service. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCTRLDeactivateAllServicesPacket() = Packet( + command = Command.CTRL_DEACTIVATE_ALL_SERVICES + ) + + /** + * Parses an CTRL_SERVICE_ERROR packet and extracts its payload. + * + * @param packet Application layer CTRL_SERVICE_ERROR packet to parse. + * @return The packet's parsed payload. + * @throws InvalidPayloadException if the payload size is not the expected size. + */ + fun parseCTRLServiceErrorPacket(packet: Packet): CTRLServiceError { + val payload = packet.payload + checkPayloadSize(packet, 5) + + return CTRLServiceError( + errorCode = parseErrorCode(payload), + serviceIDValue = payload[2].toPosInt(), + commandIDValue = (payload[3].toPosInt() shl 0) or (payload[4].toPosInt() shl 8) + ) + } + + /** + * Creates a CMD_PING packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCMDPingPacket() = Packet( + command = Command.CMD_PING + ) + + /** + * Creates a CMD_READ_DATE_TIME packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCMDReadDateTimePacket() = Packet( + command = Command.CMD_READ_DATE_TIME + ) + + /** + * Creates a CMD_READ_PUMP_STATUS packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCMDReadPumpStatusPacket() = Packet( + command = Command.CMD_READ_PUMP_STATUS + ) + + /** + * Creates a CMD_READ_ERROR_WARNING_STATUS packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCMDReadErrorWarningStatusPacket() = Packet( + command = Command.CMD_READ_ERROR_WARNING_STATUS + ) + + /** + * Creates a CMD_READ_HISTORY_BLOCK packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCMDReadHistoryBlockPacket() = Packet( + command = Command.CMD_READ_HISTORY_BLOCK + ) + + /** + * Creates a CMD_CONFIRM_HISTORY_BLOCK packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCMDConfirmHistoryBlockPacket() = Packet( + command = Command.CMD_CONFIRM_HISTORY_BLOCK + ) + + /** + * Creates a CMD_GET_BOLUS_STATUS packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createCMDGetBolusStatusPacket() = Packet( + command = Command.CMD_GET_BOLUS_STATUS + ) + + /** + * Creates a CMD_DELIVER_BOLUS packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @param bolusAmount Amount of insulin to use for the bolus. + * Note that this is given in 0.1 IU units, so for example, + * "57" means 5.7 IU. + * @return The produced packet. + */ + fun createCMDDeliverBolusPacket(bolusAmount: Int): Packet { + // Need to convert the bolus amount to a 32-bit floating point, and + // then convert that into a form that can be stored below as 4 bytes + // in little-endian order. + val bolusAmountAsFloatBits = bolusAmount.toFloat().toBits().toPosLong() + + // TODO: It is currently unknown why the 0x55 and 0x59 bytes encode + // a standard bolus, why the same bolus parameters have to be added + // twice (once as 16-bit integers and once as 32-bit floats), or + // how to program in multi-wave and extended bolus types. + + val payload = byteArrayListOfInts( + // This specifies a standard bolus. + 0x55, 0x59, + + // Total bolus amount, encoded as a 16-bit little endian integer. + (bolusAmount and 0x00FF) ushr 0, + (bolusAmount and 0xFF00) ushr 8, + // Duration in minutes, encoded as a 16-bit little endian integer. + // (Only relevant for multi-wave and extended bolus.) + 0x00, 0x00, + // Immediate bolus amount encoded as a 16-bit little endian integer. + // (Only relevant for multi-wave bolus.) + 0x00, 0x00, + + // Total bolus amount, encoded as a 32-bit little endian float point. + ((bolusAmountAsFloatBits and 0x000000FFL) ushr 0).toInt(), + ((bolusAmountAsFloatBits and 0x0000FF00L) ushr 8).toInt(), + ((bolusAmountAsFloatBits and 0x00FF0000L) ushr 16).toInt(), + ((bolusAmountAsFloatBits and 0xFF000000L) ushr 24).toInt(), + // Duration in minutes, encoded as a 32-bit little endian float point. + // (Only relevant for multi-wave and extended bolus.) + 0x00, 0x00, 0x00, 0x00, + // Immediate bolus amount encoded as a 32-bit little endian float point. + // (Only relevant for multi-wave bolus.) + 0x00, 0x00, 0x00, 0x00 + ) + + // Add a CRC16 checksum for all of the parameters + // stored in the payload above. + val crcChecksum = calculateCRC16MCRF4XX(payload) + payload.add(((crcChecksum and 0x00FF) ushr 0).toByte()) + payload.add(((crcChecksum and 0xFF00) ushr 8).toByte()) + + return Packet( + command = Command.CMD_DELIVER_BOLUS, + payload = payload + ) + } + + /** + * Creates a CMD_CANCEL_BOLUS packet. + * + * The command mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @param bolusType The type of the bolus to cancel. + * @return The produced packet. + */ + fun createCMDCancelBolusPacket(bolusType: CMDBolusType) = Packet( + command = Command.CMD_CANCEL_BOLUS, + payload = byteArrayListOfInts(bolusType.id) + ) + + /** + * Parses a CMD_READ_DATE_TIME_RESPONSE packet and extracts its payload. + * + * @param packet Application layer CMD_READ_DATE_TIME_RESPONSE packet to parse. + * @return The packet's parsed payload (the pump's current datetime). + * @throws InvalidPayloadException if the payload size is not the expected size. + */ + fun parseCMDReadDateTimeResponsePacket(packet: Packet): LocalDateTime { + logger(LogLevel.DEBUG) { "Parsing CMD_READ_DATE_TIME_RESPONSE packet" } + + // Payload size sanity check. + if (packet.payload.size != 12) { + throw InvalidPayloadException( + packet, + "Incorrect payload size in ${packet.command} packet; expected exactly 12 bytes, got ${packet.payload.size}" + ) + } + + val payload = packet.payload + + val dateTime = LocalDateTime( + second = payload[8].toPosInt(), + minute = payload[7].toPosInt(), + hour = payload[6].toPosInt(), + dayOfMonth = payload[5].toPosInt(), + monthNumber = payload[4].toPosInt(), + year = (payload[2].toPosInt() shl 0) or (payload[3].toPosInt() shl 8) + ) + + logger(LogLevel.DEBUG) { "Current pump datetime: $dateTime" } + + return dateTime + } + + /** + * Parses a CMD_READ_PUMP_STATUS_RESPONSE packet and extracts its payload. + * + * @param packet Application layer CMD_READ_PUMP_STATUS_RESPONSE packet to parse. + * @return The packet's parsed payload (the pump status). + * @throws InvalidPayloadException if the payload size is not the expected size. + */ + fun parseCMDReadPumpStatusResponsePacket(packet: Packet): CMDPumpStatus { + logger(LogLevel.DEBUG) { "Parsing CMD_READ_PUMP_STATUS_RESPONSE packet" } + + // Payload size sanity check. + if (packet.payload.size != 3) { + throw InvalidPayloadException( + packet, + "Incorrect payload size in ${packet.command} packet; expected exactly 3 bytes, got ${packet.payload.size}" + ) + } + + val payload = packet.payload + + val status = if (payload[2].toPosInt() == 0xB7) + CMDPumpStatus.RUNNING + else + CMDPumpStatus.STOPPED + + logger(LogLevel.DEBUG) { "Pump status information: $status" } + + return status + } + + /** + * Parses a CMD_READ_ERROR_WARNING_STATUS_RESPONSE packet and extracts its payload. + * + * @param packet Application layer CMD_READ_ERROR_WARNING_STATUS_RESPONSE packet to parse. + * @return The packet's parsed payload (the error/warning status). + * @throws InvalidPayloadException if the payload size is not the expected size. + */ + fun parseCMDReadErrorWarningStatusResponsePacket(packet: Packet): CMDErrorWarningStatus { + logger(LogLevel.DEBUG) { "Parsing CMD_READ_ERROR_WARNING_STATUS_RESPONSE packet" } + + // Payload size sanity check. + if (packet.payload.size != 4) { + throw InvalidPayloadException( + packet, + "Incorrect payload size in ${packet.command} packet; expected exactly 4 bytes, got ${packet.payload.size}" + ) + } + + val payload = packet.payload + + val errorWarningStatus = CMDErrorWarningStatus( + errorOccurred = (payload[2].toPosInt() == 0xB7), + warningOccurred = (payload[3].toPosInt() == 0xB7) + ) + + logger(LogLevel.DEBUG) { "Error/warning status: $errorWarningStatus" } + + return errorWarningStatus + } + + /** + * Parses a CMD_READ_HISTORY_BLOCK_RESPONSE packet and extracts its payload. + * + * @param packet Application layer CMD_READ_HISTORY_BLOCK_RESPONSE packet to parse. + * @return The packet's parsed payload (the history block). + * @throws InvalidPayloadException if the payload size is not the expected size. + * @throws PayloadDataCorruptionException if the payload contains corrupted data. + */ + fun parseCMDReadHistoryBlockResponsePacket(packet: Packet): CMDHistoryBlock { + logger(LogLevel.DEBUG) { "Parsing CMD_READ_HISTORY_BLOCK_RESPONSE packet" } + + // Payload size sanity check. + if (packet.payload.size < 7) { + throw InvalidPayloadException( + packet, + "Incorrect payload size in ${packet.command} packet; expected at least 7 bytes, got ${packet.payload.size}" + ) + } + + val payload = packet.payload + + val numEvents = payload[6].toPosInt() + + // Payload size sanity check. We expect the packet to contain + // an amount of bytes that matches the expected size of the + // events exactly. Anything else indicates that something is + // wrong with the packet. + val expectedPayloadSize = (7 + numEvents * 18) + if (packet.payload.size != expectedPayloadSize) { + throw PayloadDataCorruptionException( + packet, + "Incorrect payload size in ${packet.command} packet; expected $expectedPayloadSize bytes " + + "for a history block with $numEvents events, got ${packet.payload.size} bytes instead; " + + "event amount may have been corrupted" + ) + } + + logger(LogLevel.DEBUG) { "Packet contains $numEvents history event(s)" } + + val numRemainingEvents = (payload[2].toPosInt() shl 0) or (payload[3].toPosInt() shl 8) + val moreEventsAvailable = (payload[4].toPosInt() == 0x48) + val historyGap = (payload[5].toPosInt() == 0x48) + + logger(LogLevel.DEBUG) { + "History block information: " + + "num remaining events: $numRemainingEvents " + + "more events available: $moreEventsAvailable " + + "historyGap: $historyGap " + + "number of events: $numEvents" + } + + val events = mutableListOf() + for (eventIndex in 0 until numEvents) { + val payloadOffset = 7 + eventIndex * 18 + + // The first four bytes contain the timestamp: + // byte 0: bits 0..5 : seconds bits 6..7 : lower 2 bits of the minutes + // byte 1: bits 0..3 : upper 4 bits of the minutes bits 4..7 : lower 4 bits of the hours + // byte 2: bit 0 : highest bit of the hours bits 1..5 : days bits 6..7 : lower 2 bits of the months + // byte 3: bits 0..1 : upper 2 bits of the months bits 2..7 : years + val timestamp = LocalDateTime( + second = payload[payloadOffset + 0].toPosInt() and 0b00111111, + minute = ((payload[payloadOffset + 0].toPosInt() and 0b11000000) ushr 6) or + ((payload[payloadOffset + 1].toPosInt() and 0b00001111) shl 2), + hour = ((payload[payloadOffset + 1].toPosInt() and 0b11110000) ushr 4) or + ((payload[payloadOffset + 2].toPosInt() and 0b00000001) shl 4), + dayOfMonth = (payload[payloadOffset + 2].toPosInt() and 0b00111110) ushr 1, + monthNumber = ((payload[payloadOffset + 2].toPosInt() and 0b11000000) ushr 6) or + ((payload[payloadOffset + 3].toPosInt() and 0b00000011) shl 2), + year = ((payload[payloadOffset + 3].toPosInt() and 0b11111100) ushr 2) + 2000 + ) + + val eventTypeId = (payload[payloadOffset + 8].toPosInt() shl 0) or + (payload[payloadOffset + 9].toPosInt() shl 8) + val detailBytesCrcChecksum = (payload[payloadOffset + 10].toPosInt() shl 0) or + (payload[payloadOffset + 11].toPosInt() shl 8) + val eventCounter = (payload[payloadOffset + 12].toPosLong() shl 0) or + (payload[payloadOffset + 13].toPosLong() shl 8) or + (payload[payloadOffset + 14].toPosLong() shl 16) or + (payload[payloadOffset + 15].toPosLong() shl 24) + val eventCounterCrcChecksum = (payload[payloadOffset + 16].toPosInt() shl 0) or + (payload[payloadOffset + 17].toPosInt() shl 8) + val detailBytes = payload.subList(payloadOffset + 4, payloadOffset + 8) + + logger(LogLevel.DEBUG) { + "Event #$eventIndex: timestamp $timestamp event type ID $eventTypeId " + + "detail bytes CRC16 checksum ${detailBytesCrcChecksum.toHexString(width = 4, prependPrefix = true)} " + + "event counter $eventCounter " + + "counter CRC16 checksum ${eventCounterCrcChecksum.toHexString(width = 4, prependPrefix = true)} " + + "raw detail data bytes ${detailBytes.toHexString()}" + } + + // The eventCounterCrcChecksum is the CRC-16-MCRF4XX checksum + // of the 4 bytes that make up the event counter value. + val computedEventCounterCrcChecksum = calculateCRC16MCRF4XX(payload.subList(payloadOffset + 12, payloadOffset + 16)) + val counterIntegrityOk = computedEventCounterCrcChecksum == eventCounterCrcChecksum + if (!counterIntegrityOk) { + throw PayloadDataCorruptionException( + packet, + "Integrity check failed for counter value of event #$eventIndex; computed CRC16 is " + + "checksum ${computedEventCounterCrcChecksum.toHexString(width = 4, prependPrefix = true)}, " + + "expected checksum is ${eventCounterCrcChecksum.toHexString(width = 4, prependPrefix = true)}" + ) + } + + // The detailBytesCrcChecksum is the CRC-16-MCRF4XX checksum + // of the first 10 bytes in the event's data (the "detail bytes"). + // This includes: timestamp, detail bytes, and the event type ID. + val computedDetailCrcChecksum = calculateCRC16MCRF4XX(payload.subList(payloadOffset + 0, payloadOffset + 10)) + val detailIntegrityOk = computedDetailCrcChecksum == detailBytesCrcChecksum + if (!detailIntegrityOk) { + throw PayloadDataCorruptionException( + packet, + "Integrity check failed for detail bytes of event #$eventIndex; computed CRC16 is " + + "checksum ${computedDetailCrcChecksum.toHexString(width = 4, prependPrefix = true)}, " + + "expected checksum is ${detailBytesCrcChecksum.toHexString(width = 4, prependPrefix = true)}" + ) + } + + // All bolus amounts are recorded as an integer that got multiplied by 10. + // For example, an amount of 3.7 IU is recorded as the 16-bit integer 37. + + val eventDetail = when (eventTypeId) { + // Quick bolus. + 4, 5 -> { + // Bolus amount is recorded in the first 2 detail bytes as a 16-bit little endian integer. + val bolusAmount = (detailBytes[1].toPosInt() shl 8) or detailBytes[0].toPosInt() + // Event type ID 4 = bolus requested. ID 5 = bolus infused (= it is done). + val requested = (eventTypeId == 4) + + logger(LogLevel.DEBUG) { + "Detail info: got history event \"quick bolus ${if (requested) "requested" else "infused"}\" " + + "with amount of ${bolusAmount.toFloat() / 10} IU" + } + + if (requested) + CMDHistoryEventDetail.QuickBolusRequested( + bolusAmount = bolusAmount + ) + else + CMDHistoryEventDetail.QuickBolusInfused( + bolusAmount = bolusAmount + ) + } + + // Extended bolus. + 8, 9 -> { + // Total bolus amount is recorded in the first 2 detail bytes as a 16-bit little endian integer. + val totalBolusAmount = (detailBytes[1].toPosInt() shl 8) or detailBytes[0].toPosInt() + // Total duration in minutes is recorded in the next 2 detail bytes as a 16-bit little endian integer. + val totalDurationMinutes = (detailBytes[3].toPosInt() shl 8) or detailBytes[2].toPosInt() + // Event type ID 8 = bolus started. ID 9 = bolus ended. + val started = (eventTypeId == 8) + + logger(LogLevel.DEBUG) { + "Detail info: got history event \"extended bolus ${if (started) "started" else "ended"}\" " + + "with total amount of ${totalBolusAmount.toFloat() / 10} IU and " + + "total duration of $totalDurationMinutes minutes" + } + + if (started) + CMDHistoryEventDetail.ExtendedBolusStarted( + totalBolusAmount = totalBolusAmount, + totalDurationMinutes = totalDurationMinutes + ) + else + CMDHistoryEventDetail.ExtendedBolusEnded( + totalBolusAmount = totalBolusAmount, + totalDurationMinutes = totalDurationMinutes + ) + } + + // Multiwave bolus. + 10, 11 -> { + // All 8 bits of first byte + 2 LSB of second byte: bolus amount. + // 6 MSB of second byte + 4 LSB of third byte: immediate bolus amount. + // 4 MSB of third byte + all 8 bits of fourth byte: duration in minutes. + val totalBolusAmount = ((detailBytes[1].toPosInt() and 0b00000011) shl 8) or detailBytes[0].toPosInt() + val immediateBolusAmount = ((detailBytes[2].toPosInt() and 0b00001111) shl 6) or + ((detailBytes[1].toPosInt() and 0b11111100) ushr 2) + val totalDurationMinutes = (detailBytes[3].toPosInt() shl 4) or + ((detailBytes[2].toPosInt() and 0b11110000) ushr 4) + // Event type ID 10 = bolus started. ID 11 = bolus ended. + val started = (eventTypeId == 10) + + logger(LogLevel.DEBUG) { + "Detail info: got history event \"multiwave bolus ${if (started) "started" else "ended"}\" " + + "with total amount of ${totalBolusAmount.toFloat() / 10} IU, " + + "immediate amount of ${immediateBolusAmount.toFloat() / 10} IU, " + + "and total duration of $totalDurationMinutes minutes" + } + + if (started) + CMDHistoryEventDetail.MultiwaveBolusStarted( + totalBolusAmount = totalBolusAmount, + immediateBolusAmount = immediateBolusAmount, + totalDurationMinutes = totalDurationMinutes + ) + else + CMDHistoryEventDetail.MultiwaveBolusEnded( + totalBolusAmount = totalBolusAmount, + immediateBolusAmount = immediateBolusAmount, + totalDurationMinutes = totalDurationMinutes + ) + } + + // Standard bolus. + 6, 14, 7, 15 -> { + // Bolus amount is recorded in the first 2 detail bytes as a 16-bit little endian integer. + val bolusAmount = (detailBytes[1].toPosInt() shl 8) or detailBytes[0].toPosInt() + // Events with type IDs 6 and 7 indicate manual infusion. + // NOTE: "Manual" means that the user manually administered the bolus + // on the pump itself, with the pump's buttons. So, manual == false + // specifies that the bolus was given off programmatically (that is, + // through the CMD_DELIVER_BOLUS command). + val manual = (eventTypeId == 6) || (eventTypeId == 7) + // Events with type IDs 6 and 14 indicate that a bolus was requested, while + // events with type IDs 7 and 15 indicate that a bolus was infused (= finished). + val requested = (eventTypeId == 6) || (eventTypeId == 14) + + logger(LogLevel.DEBUG) { + "Detail info: got history event \"${if (manual) "manual" else "automatic"} " + + "standard bolus ${if (requested) "requested" else "infused"}\" " + + "with amount of ${bolusAmount.toFloat() / 10} IU" + } + + if (requested) + CMDHistoryEventDetail.StandardBolusRequested( + bolusAmount = bolusAmount, + manual = manual + ) + else + CMDHistoryEventDetail.StandardBolusInfused( + bolusAmount = bolusAmount, + manual = manual + ) + } + + // New datetime set. + 24 -> { + // byte 0: bits 0..5 : seconds bits 6..7 : lower 2 bits of the minutes + // byte 1: bits 0..3 : upper 4 bits of the minutes bits 4..7 : lower 4 bits of the hours + // byte 2: bit 0 : highest bit of the hours bits 1..5 : days bits 6..7 : lower 2 bits of the months + // byte 3: bits 0..1 : upper 2 bits of the months bits 2..7 : years + + val newDateTime = LocalDateTime( + second = detailBytes[0].toPosInt() and 0b00111111, + minute = ((detailBytes[0].toPosInt() and 0b11000000) ushr 6) or + ((detailBytes[1].toPosInt() and 0b00001111) shl 2), + hour = ((detailBytes[1].toPosInt() and 0b11110000) ushr 4) or + ((detailBytes[2].toPosInt() and 0b00000001) shl 4), + dayOfMonth = (detailBytes[2].toPosInt() and 0b00111110) ushr 1, + monthNumber = ((detailBytes[2].toPosInt() and 0b11000000) ushr 6) or + ((detailBytes[3].toPosInt() and 0b00000011) shl 2), + year = ((detailBytes[3].toPosInt() and 0b11111100) ushr 2) + 2000 + ) + + logger(LogLevel.DEBUG) { + "Detail info: got history event \"new datetime set\" with new datetime $newDateTime" + } + + CMDHistoryEventDetail.NewDateTimeSet(newDateTime) + } + else -> { + logger(LogLevel.DEBUG) { + "No detail info available: event type ID unrecognized; skipping this event" + } + continue + } + } + + events.add( + CMDHistoryEvent( + timestamp = timestamp, + eventCounter = eventCounter, + detail = eventDetail + ) + ) + } + + return CMDHistoryBlock( + numRemainingEvents = numRemainingEvents, + moreEventsAvailable = moreEventsAvailable, + historyGap = historyGap, + events = events + ) + } + + /** + * Parses a CMD_GET_BOLUS_STATUS_RESPONSE packet and extracts its payload. + * + * @param packet Application layer CMD_GET_BOLUS_STATUS_RESPONSE packet to parse. + * @return The packet's parsed payload (the current bolus delivery status). + * @throws InvalidPayloadException if the payload size is not the expected size, + * @throws PayloadDataCorruptionException if the payload contains corrupted data. + */ + fun parseCMDGetBolusStatusResponsePacket(packet: Packet): CMDBolusDeliveryStatus { + logger(LogLevel.DEBUG) { "Parsing CMD_GET_BOLUS_STATUS_RESPONSE packet" } + + // Payload size sanity check. + if (packet.payload.size != 8) { + throw InvalidPayloadException( + packet, + "Incorrect payload size in ${packet.command} packet; expected exactly 8 bytes, got ${packet.payload.size}" + ) + } + + val payload = packet.payload + + val bolusTypeInt = payload[2].toPosInt() + val bolusType = CMDBolusType.fromInt(bolusTypeInt) + ?: throw PayloadDataCorruptionException( + packet, + "Invalid bolus type ${bolusTypeInt.toHexString(2, true)}" + ) + + val deliveryStateInt = payload[3].toPosInt() + val deliveryState = CMDBolusDeliveryState.fromInt(deliveryStateInt) + ?: throw PayloadDataCorruptionException( + packet, + "Invalid delivery state ${deliveryStateInt.toHexString(2, true)}" + ) + + val bolusStatus = CMDBolusDeliveryStatus( + bolusType = bolusType, + deliveryState = deliveryState, + remainingAmount = (payload[4].toPosInt() shl 0) or (payload[5].toPosInt() shl 8) + ) + + logger(LogLevel.DEBUG) { "Bolus status: $bolusStatus" } + + return bolusStatus + } + + /** + * Parses a CMD_DELIVER_BOLUS_RESPONSE packet and extracts its payload. + * + * @param packet Application layer CMD_DELIVER_BOLUS_RESPONSE packet to parse. + * @return true if the bolus was delivered correctly, false otherwise. + * @throws InvalidPayloadException if the payload size is not the expected size, + */ + fun parseCMDDeliverBolusResponsePacket(packet: Packet): Boolean { + logger(LogLevel.DEBUG) { "Parsing CMD_DELIVER_BOLUS_RESPONSE packet" } + + // Payload size sanity check. + if (packet.payload.size != 3) { + throw InvalidPayloadException( + packet, + "Incorrect payload size in ${packet.command} packet; expected exactly 3 bytes, got ${packet.payload.size}" + ) + } + + val payload = packet.payload + + val bolusStarted = (payload[2].toPosInt() == 0x48) + + logger(LogLevel.DEBUG) { "Bolus started: $bolusStarted" } + + return bolusStarted + } + + /** + * Parses a CMD_CANCEL_BOLUS_RESPONSE packet and extracts its payload. + * + * @param packet Application layer CMD_CANCEL_BOLUS_RESPONSE packet to parse. + * @return true if the bolus was cancelled, false otherwise. + * @throws InvalidPayloadException if the payload size is not the expected size, + */ + fun parseCMDCancelBolusResponsePacket(packet: Packet): Boolean { + logger(LogLevel.DEBUG) { "Parsing CMD_CANCEL_BOLUS_RESPONSE packet" } + + // Payload size sanity check. + if (packet.payload.size != 3) { + throw InvalidPayloadException( + packet, + "Incorrect payload size in ${packet.command} packet; expected exactly 3 bytes, got ${packet.payload.size}" + ) + } + + val payload = packet.payload + + val bolusCancelled = (payload[2].toPosInt() == 0x48) + + logger(LogLevel.DEBUG) { "Bolus cancelled: $bolusCancelled" } + + return bolusCancelled + } + + /** + * Creates an RT_BUTTON_STATUS packet. + * + * The RT mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * To implement multiple pressed buttons, use a bitwise OR combination + * of IDs from the RTButtonCode enum. + * + * @param rtButtonCodes Button ID / combined button IDs. + * @param buttonStatusChanged Whether or not the button status + * actually changed since the last time the status was + * sent to the Combo. + * @return The produced packet. + */ + fun createRTButtonStatusPacket(rtButtonCodes: Int, buttonStatusChanged: Boolean) = Packet( + command = Command.RT_BUTTON_STATUS, + payload = byteArrayListOfInts( + 0x00, 0x00, // RT sequence - will be filled in later by sendPacket() + rtButtonCodes, + if (buttonStatusChanged) 0xB7 else 0x48 + ) + ) + + /** + * Creates an RT_KEEP_ALIVE packet. + * + * The RT mode must have been activated before this can be sent to the Combo. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet. + */ + fun createRTKeepAlivePacket() = Packet( + command = Command.RT_KEEP_ALIVE, + payload = byteArrayListOfInts( + 0x00, 0x00 // RT sequence - will be filled in later by sendPacket() + ) + ) + + /** + * Parses an RT_DISPLAY packet and extracts its payload. + * + * @param packet Application layer RT_DISPLAY packet to parse. + * @return The packet's parsed payload. + * @throws InvalidPayloadException if the payload size is not the expected size, + * or if the payload contains an invalid display row ID or reason. + */ + fun parseRTDisplayPacket(packet: Packet): RTDisplayPayload { + val payload = packet.payload + checkPayloadSize(packet, 5 + 96) + + val reasonInt = payload[2].toPosInt() + val reason = RTDisplayUpdateReason.fromInt(reasonInt) ?: throw InvalidPayloadException( + packet, "Invalid RT display update reason $reasonInt") + + val row = when (val rowInt = payload[4].toPosInt()) { + 0x47 -> 0 + 0x48 -> 1 + 0xB7 -> 2 + 0xB8 -> 3 + else -> throw InvalidPayloadException(packet, "Invalid RT display update row $rowInt") + } + + return RTDisplayPayload( + currentRTSequence = (payload[0].toPosInt() shl 0) or (payload[1].toPosInt() shl 8), + reason = reason, + index = payload[3].toPosInt(), + row = row, + rowBytes = payload.subList(5, 101) + ) + } + + /** + * Parses an RT_AUDIO packet and extracts its payload. + * + * @param packet Application layer RT_AUDIO packet to parse. + * @return The packet's parsed payload (the 32-bit little + * endian integer specifying the audio type). + * @throws InvalidPayloadException if the payload size is not the expected size. + */ + fun parseRTAudioPacket(packet: Packet): Int { + val payload = packet.payload + checkPayloadSize(packet, 6) + + // The first 2 bytes in the payload contain the RT + // sequence number, which we are not interested in, + // so we ignore these 2 bytes. + + return (payload[2].toPosInt() shl 0) or + (payload[3].toPosInt() shl 8) or + (payload[4].toPosInt() shl 16) or + (payload[5].toPosInt() shl 24) + } + + /** + * Parses an RT_VIBRATION packet and extracts its payload. + * + * @param packet Application layer RT_VIBRATION packet to parse. + * @return The packet's parsed payload (the 32-bit little + * endian integer specifying the vibration type). + * @throws InvalidPayloadException if the payload size is not the expected size. + */ + fun parseRTVibrationPacket(packet: Packet): Int { + val payload = packet.payload + checkPayloadSize(packet, 6) + + // The first 2 bytes in the payload contain the RT + // sequence number, which we are not interested in, + // so we ignore these 2 bytes. + + return (payload[2].toPosInt() shl 0) or + (payload[3].toPosInt() shl 8) or + (payload[4].toPosInt() shl 16) or + (payload[5].toPosInt() shl 24) + } + + /** + * Utility function to produce an application layer packet out of a transport layer packet, with extra checks. + * + * This is preferred over directly using the [Packet] constructor. + */ + fun checkAndParseTransportLayerDataPacket(tpLayerPacket: TransportLayer.Packet): + ApplicationLayer.Packet { + try { + logger(LogLevel.VERBOSE) { "Parsing DATA packet as application layer packet" } + val appLayerPacket = ApplicationLayer.Packet(tpLayerPacket) + logger(LogLevel.VERBOSE) { + "This is an application layer packet with command ${appLayerPacket.command} and payload ${appLayerPacket.payload.toHexString()}" + } + + // Application layer packets which were transmitted inside a transport + // layer packet with the reliability bit set always have a 16-bit error + // code in their first 2 bytes. Parse that error code, and if it is not + // set to NO_ERROR, throw an exception, since it is not known how to + // recover from those errors. + // An exception is made for CTRL_SERVICE_ERROR packets. These are passed + // through, since they are supposed to inform the caller about an error. + if ((tpLayerPacket.reliabilityBit) && (appLayerPacket.command != ApplicationLayer.Command.CTRL_SERVICE_ERROR)) { + logger(LogLevel.VERBOSE) { + "This packet was delivered inside a reliable transport layer packet; checking the error code" + } + + val errorCode = parseErrorCode(appLayerPacket.payload) + + if (errorCode != ErrorCode.Known(ErrorCode.Known.Code.NO_ERROR)) { + logger(LogLevel.ERROR) { "Application layer packet has error code $errorCode" } + throw ErrorCodeException(appLayerPacket, errorCode) + } + } + + return appLayerPacket + } catch (e: ApplicationLayer.InvalidCommandIDException) { + logger(LogLevel.WARN) { + "Got an application layer packet with invalid/unknown command ID 0x${e.commandID.toString(16)} " + + "service ID ${e.serviceID.name} and payload (with ${e.payload.size} byte(s)) ${e.payload.toHexString()}" + + "" + } + throw e + } catch (e: ErrorCodeException) { + // We already logged the error code, so just pass through the exception + throw e + } catch (e: ApplicationLayer.ExceptionBase) { + logger(LogLevel.ERROR) { "Could not parse DATA packet as application layer packet: $e" } + throw e + } + } + + private fun checkPayloadSize(packet: Packet, expectedPayloadSize: Int) { + if (packet.payload.size != expectedPayloadSize) { + throw InvalidPayloadException( + packet, + "Incorrect payload size in ${packet.command} packet; expected $expectedPayloadSize byte(s), got ${packet.payload.size}" + ) + } + } +} + +internal fun TransportLayer.Packet.toAppLayerPacket() = + ApplicationLayer.checkAndParseTransportLayerDataPacket(this) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothAddress.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothAddress.kt new file mode 100644 index 0000000000..90675b429a --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothAddress.kt @@ -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) : Iterable { + /** + * 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() }) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothDevice.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothDevice.kt new file mode 100644 index 0000000000..fc28bbe1ea --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothDevice.kt @@ -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() +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothException.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothException.kt new file mode 100644 index 0000000000..f2f73ab9ba --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothException.kt @@ -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) +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothInterface.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothInterface.kt new file mode 100644 index 0000000000..e7eda120fa --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/BluetoothInterface.kt @@ -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 +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/CRC.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/CRC.kt new file mode 100644 index 0000000000..3081dd7960 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/CRC.kt @@ -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, 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 +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Cipher.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Cipher.kt new file mode 100644 index 0000000000..e93b359769 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Cipher.kt @@ -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 +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboException.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboException.kt new file mode 100644 index 0000000000..7f9a2c36aa --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboException.kt @@ -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) +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboFrame.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboFrame.kt new file mode 100644 index 0000000000..f2207fe2d5 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboFrame.kt @@ -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) { + 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? { + // 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() + 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 { + // 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() + 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.toComboFrame(): List { + val escapedFrameData = ArrayList() + + 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 +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboIO.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboIO.kt new file mode 100644 index 0000000000..6d4a4771d8 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ComboIO.kt @@ -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) + + /** + * 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 +} + +/** + * 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) { + withContext(ioDispatcher) { + blockingSend(dataToSend) + } + } + + final override suspend fun receive(): List { + 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) + + /** + * 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 +} + +/** + * 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) = io.send(dataToSend.toComboFrame()) + + override suspend fun receive(): List { + 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) +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Constants.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Constants.kt new file mode 100644 index 0000000000..ab0026d2db --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Constants.kt @@ -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 +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Dispatchers.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Dispatchers.kt new file mode 100644 index 0000000000..7bafe79051 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Dispatchers.kt @@ -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) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/DisplayFrame.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/DisplayFrame.kt new file mode 100644 index 0000000000..a28962cbb2 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/DisplayFrame.kt @@ -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 { + /** + * 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 }) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/DisplayFrameAssembler.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/DisplayFrameAssembler.kt new file mode 100644 index 0000000000..e0674f292b --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/DisplayFrameAssembler.kt @@ -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?>(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): 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) + } +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Graph.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Graph.kt new file mode 100644 index 0000000000..b561fd3bc5 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Graph.kt @@ -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().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 { + /** + * 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() + val edges: List = _edges + + internal fun connectTo(targetNode: Node, edgeValue: EdgeValue): Edge { + val newEdge = Edge(edgeValue, targetNode) + _edges.add(newEdge) + return newEdge + } + } + + private val _nodes = mutableMapOf() + val nodes: Map = _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(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 Graph.findShortestPath( + from: NodeValue, + to: NodeValue, + edgePredicate: (edgeValue: EdgeValue) -> Boolean = { true } +): List>? { + 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 Graph.findShortestPath( + fromNode: Graph.Node, + toNode: Graph.Node, + edgePredicate: (edgeValue: EdgeValue) -> Boolean = { true } +): List>? { + if (fromNode === toNode) + return listOf() + + val visitedNodes = mutableListOf.Node>() + val path = mutableListOf>() + + fun visitAdjacentNodes(node: Graph.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 connectDirectionally( + fromToEdgeValue: EdgeValue, + vararg nodes: Graph.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 connectBidirectionally( + fromToEdgeValue: EdgeValue, + toFromEdgeValue: EdgeValue, + vararg nodes: Graph.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 Graph.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 +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Logger.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Logger.kt new file mode 100644 index 0000000000..31b81bcdd8 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Logger.kt @@ -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) +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/MachineAuthCode.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/MachineAuthCode.kt new file mode 100644 index 0000000000..86eabbc190 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/MachineAuthCode.kt @@ -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) : Iterable { + /** + * 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 }) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Nonce.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Nonce.kt new file mode 100644 index 0000000000..31dd8f873e --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Nonce.kt @@ -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) : Iterable { + /** + * 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(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() }) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PairingPIN.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PairingPIN.kt new file mode 100644 index 0000000000..bddd652705 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PairingPIN.kt @@ -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 { + // 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)) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ProgressReporter.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ProgressReporter.kt new file mode 100644 index 0000000000..5bd6403934 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/ProgressReporter.kt @@ -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( + private val plannedSequence: List, + 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) + ) + } +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PumpIO.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PumpIO.kt new file mode 100644 index 0000000000..aeca814ec7 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PumpIO.kt @@ -0,0 +1,2040 @@ +package info.nightscout.comboctl.base + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.offsetAt + +private val logger = Logger.get("PumpIO") + +private object PumpIOConstants { + const val MAX_NUM_REGULAR_CONNECTION_ATTEMPTS = 3 + const val NONCE_INCREMENT = 500 +} + +/** + * Callback used during pairing for asking the user for the 10-digit PIN. + * + * This is passed to [PumpIO.performPairing] when pairing. + * + * [previousAttemptFailed] is useful for showing in a GUI that the + * previously entered PIN seems to be wrong and that the user needs + * to try again. + * + * If the user wants to cancel the pairing instead of entering the + * PIN, cancelling the coroutine where [PumpIO.performPairing] runs + * is sufficient. + * + * @param previousAttemptFailed true if the user was already asked for + * the PIN and the KEY_RESPONSE authentication failed. + */ +typealias PairingPINCallback = suspend (previousAttemptFailed: Boolean) -> PairingPIN + +/** + * Class for high-level Combo pump IO. + * + * This implements high level IO actions on top of [TransportLayer.IO]. + * It takes care of pairing, connection setup, remote terminal (RT) + * commands and RT display reception, and also supports the Combo's + * command mode. Basically, this class' public API reflects what the + * user can directly do with the pump (press RT buttons like UP or + * DOWN, send a command mode bolus etc.). + * + * For initiating the Combo pairing, the [performPairing] function is + * available. This must not be used if a connection was already established + * via [connect] - pairing is only possible in the disconnected state. + * + * For initiating a regular connection, use [connect]. Do not call + * [connect] again until after disconnecting with [disconnect]. Also + * see the remarks above about pairing and connecting at the same time. + * + * The Combo regularly sends new display frames when running in the + * REMOTE_TERMINAL (RT) mode. These frames come as the payload of + * RT_DISPLAY packets. This class reads those packets and extracts the + * partial frames (the packets only contain portions of a frame, not + * a full frame). Once enough parts were gathered to assemble a full + * frame, the frame is emitted via [onNewDisplayFrame]. This callback + * must not block, since this would otherwise block the dataflow in the + * internal IO code. This callback is mainly meant for passing the + * frame to something like a flow or a channel. If its argument is + * null, it means that there's no frame available. This happens at + * the beginning in [connect] and during the [switchMode] call. + * + * To handle IO at the transport layer, this uses [TransportLayer.IO] + * internally. + * + * In regular connections, the Combo needs "heartbeats" to periodically + * let it know that the client still exists. If too much time passes since + * the last heartbeat, the Combo terminates the connection. Each mode has a + * different type of heartbeat: RT mode has RT_KEEP_ALIVE commands, command + * mode has CMD_PING commands. To periodically send these, this class runs + * separate coroutines with loops inside that send these commands. Only one + * of these two heartbeats are active, depending on the current mode of the + * Combo. + * Note that other commands sent to the Combo _also_ count as heartbeats, + * so RT_KEEP_ALIVE / CMD_PING only need to be sent by the internal heartbeat + * if no other command has been sent for a while now. + * In some cases (typically unit tests), a regular connection without + * a heartbeat is needed. [connect] accepts an argument to start + * without one for this purpose. + * + * The supplied [pumpStateStore] is used during pairing and regular + * connections. During pairing, a new pump state is set up for the + * pump that is being paired. It is during pairing that the invariant + * portion of the pump state is written. During regular (= not pairing) + * connections, the invariant part is read, not written. + * + * This class accesses the pump state in a thread safe manner, ensuring + * that no two threads access the pump state at the same time. See + * [PumpStateStore] for details about thread safety. + * + * @param pumpStateStore Pump state store to use. + * @param bluetoothDevice [BluetoothDevice] object to use for + * Bluetooth I/O. Must be in a disconnected state when + * assigned to this instance. + * @param onNewDisplayFrame Callback to invoke whenever a new RT + * [DisplayFrame] was received. + * @param onPacketReceiverException Callback to invoked whenever an + * exception is thrown inside the transport layer's receiver loop. + * This is useful for automatic reconnecting. + */ +class PumpIO( + private val pumpStateStore: PumpStateStore, + private val bluetoothDevice: BluetoothDevice, + private val onNewDisplayFrame: (displayFrame: DisplayFrame?) -> Unit, + private val onPacketReceiverException: (e: TransportLayer.PacketReceiverException) -> Unit +) { + // Mutex to synchronize sendPacketWithResponse and sendPacketWithoutResponse calls. + private val sendPacketMutex = Mutex() + + // RT sequence number. Used in outgoing RT packets. + private var currentRTSequence: Int = 0 + + // Pass IO through the FramedComboIO class since the Combo + // sends packets in a framed form (See [ComboFrameParser] + // and [List.toComboFrame] for details). + private val framedComboIO = FramedComboIO(bluetoothDevice) + + private var initialMode: PumpIO.Mode? = null + + private var transportLayerIO = TransportLayer.IO( + pumpStateStore, bluetoothDevice.address, framedComboIO + ) { packetReceiverException -> + // If the packet receiver fails, close the barrier to wake + // up any caller that is waiting on it. + rtButtonConfirmationBarrier.close(packetReceiverException) + // Also forward the exception to the associated callback. + onPacketReceiverException(packetReceiverException) + } + + private var internalScopeJob: Job? = null + private var internalScope: CoroutineScope? = null + + // Job representing the coroutine that runs the CMD ping heartbeat. + private var cmdPingHeartbeatJob: Job? = null + // Job representing the coroutine that runs the RT keep-alive heartbeat. + private var rtKeepAliveHeartbeatJob: Job? = null + + // Members associated with long-pressing buttons in RT mode. + // Long-pressing is implemented by repeatedly sending RT_BUTTON_STATUS + // messages until the user "releases" the buttons. + // (We use a list of Button values in case multiple buttons are being + // held down at the same time.) + // We use a Deferred instance instead of Job to be able to catch + // and store exceptions & rethrow them later. + private var currentLongRTPressJob: Deferred? = null + private var currentLongRTPressedButtons = listOf() + private var longRTPressLoopRunning = true + + // A Channel that is used as a "barrier" of sorts to block button + // pressing functions from continuing until the Combo sends + // a confirmation for the key press. Up until that confirmation + // is received, the client must not send any other button press + // commands to the Combo. To ensure that, this barrier exists. + // Its payload is a Boolean to let waiting coroutines know whether + // to finish or to continue any internal loops. The former happens + // during disconnect. It is set up as a conflated channel. That + // way, if a confirmation is received before button press commands + // call receive(), information about the confirmation is not lost + // (which would happen with a rendezvous channel). And, in case + // disconnect() is called, it is important to overwrite any other + // existing value with "false" to stop button pressing commands + // (hence a conflated channel instead of DROP_OLDEST buffer + // overflow behavior). + private var rtButtonConfirmationBarrier = newRtButtonConfirmationBarrier() + + private val displayFrameAssembler = DisplayFrameAssembler() + + // Whether we are in RT or COMMAND mode, or null at startup + // before an initial mode was set. + private val _currentModeFlow = MutableStateFlow(null) + + /************************************ + *** PUBLIC FUNCTIONS AND CLASSES *** + ************************************/ + + /** + * The mode the pump can operate in. + */ + enum class Mode(val str: String) { + REMOTE_TERMINAL("REMOTE_TERMINAL"), + COMMAND("COMMAND"); + + override fun toString() = str + } + + /** + * Current connection state. + */ + enum class ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + + /** + * State after a pump command failed, typically because of an IO error. + * + * When this state is reached, nothing more can be done with the pump + * until [disconnect] is called. + */ + FAILED + } + + /** + * Exception thrown after all attempts to establish a regular connection failed. + * + * See [connect] for details about the regular connection attempts. + */ + class ConnectionRequestIsNotBeingAcceptedException : + ComboIOException("All attempts to have the Combo accept connection request after establishing Bluetooth socket failed") + + /** + * The pump's Bluetooth address. + */ + val address: BluetoothAddress = bluetoothDevice.address + + /** + * Read-only [StateFlow] property that announces when the current [Mode] changes. + * + * This flow's value is null until the connection is fully established (at which point + * the mode is set to [PumpIO.Mode.REMOTE_TERMINAL] or [PumpIO.Mode.COMMAND]), and + * set back to null again after disconnecting. + */ + val currentModeFlow = _currentModeFlow.asStateFlow() + + private var _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + + /** + * Read-only [StateFlow] property that notifies about connection state changes. + */ + val connectionState: StateFlow = _connectionState.asStateFlow() + + /** + * Returns whether this pump has already been paired. + * + * "Pairing" refers to the custom Combo pairing here, not the Bluetooth pairing. + * It is not possible to get a valid [PumpIO] instance without the device having + * been paired at the Bluetooth level before anyway. + * + * This detects whether the Combo pairing has been performed by looking at the + * persistent state associated with this [PumpIO] instance. If the state + * is set to valid values, then the pump is assumed to be paired. If the persistent + * state is in its initial state (ciphers set to null, key response address + * set to null), then it is assumed to be unpaired. + * + * @return true if the pump is paired. + */ + fun isPaired() = pumpStateStore.hasPumpState(bluetoothDevice.address) + + /** + * Performs a pairing procedure with a Combo. + * + * This performs the Combo-specific pairing. When this is called, + * the pump must have been paired at the Bluetooth level already. + * From Bluetooth's point of view, the pump is already paired with + * the client at this point. But the Combo itself needs an additional + * custom pairing. As part of this extra pairing, this function sets + * up a special temporary pairing connection to the Combo, and terminates + * that connection before finishing. Manually setting up such a connection + * is not necessary and not supported by the public API. + * + * However, the Bluetooth connection setup and teardown _is_ handled + * by this function. If the Combo-specific pairing fails, this also + * automatically unpairs the pump at the Bluetooth level. + * + * Cancelling the coroutine this function runs in will abort the pairing + * process in an orderly fashion. + * + * Pairing will initialize a new state for this pump [PumpStateStore] that + * was passed to the constructor of this class. This state will contain + * new pairing data, a new pump ID string, and a new initial nonce. + * + * The [onPairingPIN] block has two arguments. previousAttemptFailed + * is set to false initially, and true if this is a repeated call due + * to a previous failure to apply the PIN. Such a failure typically + * happens because the user mistyped the PIN, but in rare cases can also + * happen due to corrupted packets. + * + * 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. + * + * WARNING: Do not run multiple performPairing functions simultaneously + * on the same pump. Otherwise, undefined behavior occurs. + * + * @param bluetoothFriendlyName The Bluetooth friendly name to use in + * REQUEST_ID packets. Use [BluetoothInterface.getAdapterFriendlyName] + * to get the friendly name. + * @param progressReporter [ProgressReporter] for tracking pairing progress. + * @param onPairingPIN Suspending block that asks the user for + * the 10-digit pairing PIN during the pairing process. + * @throws IllegalStateException if this is ran while a connection + * is running. + * @throws PumpStateAlreadyExistsException if the pump was already + * fully paired before. + * @throws TransportLayer.PacketReceiverException if an exception + * is thrown while this function is waiting for a packet. + */ + suspend fun performPairing( + bluetoothFriendlyName: String, + progressReporter: ProgressReporter?, + onPairingPIN: suspend (newPumpAddress: BluetoothAddress, previousAttemptFailed: Boolean) -> PairingPIN + ) { + check(!isPaired()) { + "Attempting to pair with pump with address ${bluetoothDevice.address} even though it is already paired" + } + + check(!isIORunning()) { + "Attempted to perform pairing while pump with address ${bluetoothDevice.address} is connected" + } + + // This flag is used for checking if we need to unpair + // the Bluetooth device before leaving this function. This + // makes sure that in case of an error, any established + // Bluetooth pairing is undone, and the persistent state + // is reverted to its initial state. + var doUnpair = true + + // Make sure the frame parser has no leftover data from + // a previous connection. + framedComboIO.reset() + + // Set up a custom coroutine scope to run the packet receiver in. + coroutineScope { + try { + _connectionState.value = ConnectionState.CONNECTING + + // Connecting to Bluetooth may block, so run it in + // a coroutine with an IO dispatcher. + withContext(bluetoothDevice.ioDispatcher) { + bluetoothDevice.connect() + } + + _connectionState.value = ConnectionState.CONNECTED + + transportLayerIO.start(packetReceiverScope = this) { tpLayerPacket -> processReceivedPacket(tpLayerPacket) } + + progressReporter?.setCurrentProgressStage(BasicProgressStage.PerformingConnectionHandshake) + + // Initiate pairing and wait for the response. + // (The response contains no meaningful payload.) + logger(LogLevel.DEBUG) { "Sending pairing connection request" } + sendPacketWithResponse( + TransportLayer.createRequestPairingConnectionPacketInfo(), + TransportLayer.Command.PAIRING_CONNECTION_REQUEST_ACCEPTED + ) + + // Initiate pump-client and client-pump keys request. + // This will cause the pump to generate and show a + // 10-digit PIN. + logger(LogLevel.DEBUG) { "Requesting the pump to generate and show the pairing PIN" } + sendPacketWithoutResponse(TransportLayer.createRequestKeysPacketInfo()) + + progressReporter?.setCurrentProgressStage(BasicProgressStage.ComboPairingKeyAndPinRequested) + + logger(LogLevel.DEBUG) { "Requesting the keys from the pump" } + val keyResponsePacket = sendPacketWithResponse( + TransportLayer.createGetAvailableKeysPacketInfo(), + TransportLayer.Command.KEY_RESPONSE + ) + + logger(LogLevel.DEBUG) { "Will ask for pairing PIN" } + var previousPINAttemptFailed = false + + lateinit var keyResponseInfo: KeyResponseInfo + while (true) { + logger(LogLevel.DEBUG) { "Waiting for the PIN to be provided" } + + // Request the PIN. If canceled, PairingAbortedException is + // thrown by the callback. + val pin = onPairingPIN(bluetoothDevice.address, previousPINAttemptFailed) + + logger(LogLevel.DEBUG) { "Provided PIN: $pin" } + + val weakCipher = Cipher(generateWeakKeyFromPIN(pin)) + logger(LogLevel.DEBUG) { "Generated weak cipher key ${weakCipher.key.toHexString()} out of pairing PIN" } + + if (keyResponsePacket.verifyAuthentication(weakCipher)) { + logger(LogLevel.DEBUG) { "KEY_RESPONSE packet verified" } + keyResponseInfo = processKeyResponsePacket(keyResponsePacket, weakCipher) + // Exit the loop since we successfully verified the packet. + break + } else { + logger(LogLevel.DEBUG) { "Could not verify KEY_RESPONSE packet; user may have entered PIN incorrectly; asking again for PIN" } + previousPINAttemptFailed = true + } + } + + // Manually set the cached invariant pump data inside transportLayerIO, + // otherwise the next outgoing packets will not be properly authenticated + // (and their address bytes won't be valid). We'll update this later on + // with the final version of the invariant data. That's also the one + // that will be written into the pump state store. + transportLayerIO.setManualInvariantPumpData( + InvariantPumpData( + clientPumpCipher = keyResponseInfo.clientPumpCipher, + pumpClientCipher = keyResponseInfo.pumpClientCipher, + keyResponseAddress = keyResponseInfo.keyResponseAddress, + pumpID = "" + ) + ) + + logger(LogLevel.DEBUG) { "Requesting the pump ID from the pump" } + val idResponsePacket = sendPacketWithResponse( + TransportLayer.createRequestIDPacketInfo(bluetoothFriendlyName), + TransportLayer.Command.ID_RESPONSE + ) + val pumpID = processIDResponsePacket(idResponsePacket) + + val newPumpData = InvariantPumpData( + clientPumpCipher = keyResponseInfo.clientPumpCipher, + pumpClientCipher = keyResponseInfo.pumpClientCipher, + keyResponseAddress = keyResponseInfo.keyResponseAddress, + pumpID = pumpID + ) + transportLayerIO.setManualInvariantPumpData(newPumpData) + + val currentSystemDateTime = Clock.System.now() + val currentSystemTimeZone = TimeZone.currentSystemDefault() + val currentSystemUtcOffset = currentSystemTimeZone.offsetAt(currentSystemDateTime) + + pumpStateStore.createPumpState( + bluetoothDevice.address, + newPumpData, + currentSystemUtcOffset, + CurrentTbrState.NoTbrOngoing + ) + + val firstTxNonce = Nonce( + byteArrayListOfInts( + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ) + ) + pumpStateStore.setCurrentTxNonce(bluetoothDevice.address, firstTxNonce) + + progressReporter?.setCurrentProgressStage(BasicProgressStage.ComboPairingFinishing) + + // Initiate a regular (= non-pairing) transport layer connection. + // Note that we are still pairing - it just continues in the + // application layer. For this to happen, we need a regular + // _transport layer_ connection. + // Wait for the response and verify it. + logger(LogLevel.DEBUG) { "Sending regular connection request" } + sendPacketWithResponse( + TransportLayer.createRequestRegularConnectionPacketInfo(), + TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED + ) + + // Initiate application-layer connection and wait for the response. + // (The response contains no meaningful payload.) + logger(LogLevel.DEBUG) { "Initiating application layer connection" } + sendPacketWithResponse( + ApplicationLayer.createCTRLConnectPacket(), + ApplicationLayer.Command.CTRL_CONNECT_RESPONSE + ) + + // Next, we have to query the versions of both command mode and + // RT mode services. It is currently unknown how to interpret + // the version numbers, but apparently we _have_ to query them, + // otherwise the pump considers it an error. + // TODO: Further verify this. + logger(LogLevel.DEBUG) { "Requesting command mode service version" } + sendPacketWithResponse( + ApplicationLayer.createCTRLGetServiceVersionPacket(ApplicationLayer.ServiceID.COMMAND_MODE), + ApplicationLayer.Command.CTRL_GET_SERVICE_VERSION_RESPONSE + ) + // NOTE: These two steps may not be necessary. See the + // "Application layer pairing" section in the spec. + /* + sendPacketWithResponse( + ApplicationLayer.ApplicationLayer.createCTRLGetServiceVersionPacket(ApplicationLayer.ServiceID.RT_MODE), + ApplicationLayer.Command.CTRL_GET_SERVICE_VERSION_RESPONSE + ) + */ + + // Next, send a BIND command and wait for the response. + // (The response contains no meaningful payload.) + logger(LogLevel.DEBUG) { "Sending BIND command" } + sendPacketWithResponse( + ApplicationLayer.createCTRLBindPacket(), + ApplicationLayer.Command.CTRL_BIND_RESPONSE + ) + + // We have to re-connect the regular connection at the + // transport layer now. (Unclear why, but it seems this + // is necessary for the pairing process to succeed.) + // Wait for the response and verify it. + logger(LogLevel.DEBUG) { "Reconnecting regular connection" } + sendPacketWithResponse( + TransportLayer.createRequestRegularConnectionPacketInfo(), + TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED + ) + + // Pairing complete. + doUnpair = false + logger(LogLevel.DEBUG) { "Pairing finished successfully - sending CTRL_DISCONNECT to Combo" } + } catch (e: CancellationException) { + logger(LogLevel.DEBUG) { "Pairing cancelled - sending CTRL_DISCONNECT to Combo" } + throw e + } catch (t: Throwable) { + logger(LogLevel.ERROR) { + "Pairing aborted due to throwable - sending CTRL_DISCONNECT to Combo; " + + "throwable details: ${t.stackTraceToString()}" + } + throw t + } finally { + val disconnectPacketInfo = ApplicationLayer.createCTRLDisconnectPacket() + transportLayerIO.stop( + disconnectPacketInfo.toTransportLayerPacketInfo(), + ::disconnectBTDeviceAndCatchExceptions + ) + + _connectionState.value = ConnectionState.DISCONNECTED + + disconnectBTDeviceAndCatchExceptions() + + if (doUnpair) { + // Unpair in a separate context, since this + // can block for up to a second or so. + withContext(bluetoothDevice.ioDispatcher) { + bluetoothDevice.unpair() + } + pumpStateStore.deletePumpState(address) + } + } + } + } + + /** + * Establishes a regular connection. + * + * A "regular connection" is a connection that is used for typical Combo + * operations such as sending bolus commands, sending RT button presses etc. + * The client must have been paired with the Combo before such connections + * can be established. + * + * This function suspends the calling coroutine until the connection is up + * and running or a connection setup error occurs, which will cause an + * exception to be thrown. If this happens, users must call [disconnect] + * before doing anything else again with this [PumpIO] instance. + * + * The Bluetooth connection is set up by this function. [connectionState] + * is updated by it as well, as is the [connectProgressReporter] that is + * passed to this function as an argument. + * + * This must be called before [switchMode] and any RT / command mode + * function are used. + * + * Transport layer packets are received in a background coroutine that is part + * of an internal coroutine scope. Reception is handled by [TransportLayer.IO]. + * + * [isIORunning] will return true if the connection was established. + * + * [disconnect] is the counterpart to this function. It terminates + * an existing connection and stops the worker. + * + * This also starts the "heartbeat" (unless explicitly requested not to). + * See the [PumpIO] documentation above for details. + * + * The [connectionState] is set to [ConnectionState.FAILED] in case of + * an exception, unless it is a [CancellationException], in which case + * this function performs a normal disconnection by calling [disconnect]. + * + * If the [connectProgressReporter] isn't null, progress is reported through + * it to the caller. The [BasicProgressStage.Aborted] (and its subclasses), + * [BasicProgressStage.Finished], and [BasicProgressStage.Cancelled] stages + * are _not_ set; this is up to the caller. Likewise, the progress reporter + * is not automatically reset; if it requires a reset, the caller must do + * that by calling [ProgressReporter.reset]. This is done that way because + * the caller may implement some mechanism to retry a connection attempt; + * in such a case, setting these stages and resetting the reported would + * interfere, so this is not done. The [connectProgressReporter] must contain + * the [BasicProgressStage.PerformingConnectionHandshake] stage in its + * planned progress sequence (see [ProgressReporter] for details). + * + * This function also handles a special situation if the [Nonce] that is + * stored in [PumpStateStore] for this pump is incorrect. The Bluetooth + * socket can then be successfully connected, but right afterwards, when + * this function tries to send a [TransportLayer.Command.REQUEST_REGULAR_CONNECTION] + * packet, the Combo does not respond, instead terminating the connection + * and producing a [BluetoothException]. If this happens, this function + * increments the nonce and tries again. This is done multiple times + * until either the connection setup succeeds or the maximum number of + * attempts is reached. In the latter case, this function throws a + * [ConnectionRequestIsNotBeingAcceptedException]. The user should then + * be recommended to re-pair with the Combo, since establishing a connection + * isn't working. + * + * @param initialMode What mode to initially switch to. + * @param runHeartbeat True if the heartbeat shall be started. + * @param connectProgressReporter Optional [ProgressReporter] to update + * during the connection progress. + * @throws ConnectionRequestIsNotBeingAcceptedException if connecting the + * actual Bluetooth socket succeeds, but the Combo does not accept the + * packet that requests a connection, and this failed several times + * in a row. + * @throws IllegalStateException if IO was already started by a previous + * [connect] call or if the [PumpStateStore] that was passed to the + * class constructor has no pairing data for this pump (most likely + * because the pump isn't paired yet). + */ + suspend fun connect( + initialMode: Mode = Mode.REMOTE_TERMINAL, + runHeartbeat: Boolean = true, + connectProgressReporter: ProgressReporter? = null + ) { + // Prerequisites. + + check(isPaired()) { + "Attempted to connect without a valid persistent state; pairing may not have been done" + } + check(!isIORunning()) { + "Attempted to connect even though a connection is already ongoing or established" + } + + // Reset the display frame assembler in case it contains + // partial frames from an earlier connection. + displayFrameAssembler.reset() + + // Tell the callback that there's currently no frame available. + onNewDisplayFrame(null) + + // Reinitialize the barrier, since it may have been closed + // earlier due to an exception in the packet receiver. + // (See the transportLayerIO initialization code.) + rtButtonConfirmationBarrier = newRtButtonConfirmationBarrier() + + // Start the internal coroutine scope that will run the heartbeat, + // packet receiver, and other internal coroutines. Enforce the + // default dispatcher to rule out that something like the UI + // scope could be picked automatically on some platforms. + val newScopeJob = SupervisorJob() + val newScope = CoroutineScope(newScopeJob + Dispatchers.Default) + + this.initialMode = initialMode + this.internalScopeJob = newScopeJob + this.internalScope = newScope + + // Make sure the frame parser has no leftover data from + // a previous connection. + framedComboIO.reset() + + logger(LogLevel.DEBUG) { "Pump IO connecting asynchronously" } + + try { + _connectionState.value = ConnectionState.CONNECTING + + // The Combo does not tell us if the nonce is wrong. We have to infer + // that from its behavior. If the nonce is wrong, then the Bluetooth + // socket can be connected, but sending the REQUEST_REGULAR_CONNECTION + // packet fails - the expected response is not received, and instead, + // a BluetoothException occurs. In this particular case - Bluetooth + // connect() call succeeds, sendPacketWithResponse() call that shall + // send REQUEST_REGULAR_CONNECTION fails with BluetoothException - we + // may have an incorrect nonce. Increment the nonce by NONCE_INCREMENT, + // then retry. We retry a limited number of times. If sending the + // REQUEST_REGULAR_CONNECTION still fails, then we give up, and throw + // an exception that shall show on a UI a message to the user that + // establishing a connection isn't working and the user should consider + // re-pairing the pump instead. + var regularConnectionRequestAccepted = false + for (regularConnectionAttemptNr in 0 until PumpIOConstants.MAX_NUM_REGULAR_CONNECTION_ATTEMPTS) { + // Suspend the coroutine until Bluetooth is connected. + // Do this in a separate coroutine with an IO dispatcher + // since the connection setup may block. + withContext(bluetoothDevice.ioDispatcher) { + bluetoothDevice.connect() + } + + connectProgressReporter?.setCurrentProgressStage(BasicProgressStage.PerformingConnectionHandshake) + + try { + // Start the actual IO activity. + transportLayerIO.start(newScope) { tpLayerPacket -> processReceivedPacket(tpLayerPacket) } + + logger(LogLevel.DEBUG) { "Sending regular connection request" } + + // Initiate connection at the transport layer. + sendPacketWithResponse( + TransportLayer.createRequestRegularConnectionPacketInfo(), + TransportLayer.Command.REGULAR_CONNECTION_REQUEST_ACCEPTED + ) + + regularConnectionRequestAccepted = true + + // Exit the connection-attempt for-loop, since we are done. + break + } catch (e: TransportLayer.PacketReceiverException) { + logger(LogLevel.INFO) { + "Successfully set up Bluetooth socket, but attempting to send " + + "the regular connection request packet failed; exception: ${e.cause}" + } + logger(LogLevel.INFO) { + "Nonce might be wrong; incrementing nonce by ${PumpIOConstants.NONCE_INCREMENT} " + + "and retrying (attempt $regularConnectionAttemptNr of " + + "${PumpIOConstants.MAX_NUM_REGULAR_CONNECTION_ATTEMPTS})" + } + + // Call this to reset the states in the transport layer IO object + // and to disconnect Bluetooth. Otherwise we cannot call + // transportLayerIO.start() later again. + transportLayerIO.stop(disconnectPacketInfo = null, ::disconnectBTDeviceAndCatchExceptions) + + pumpStateStore.incrementTxNonce(bluetoothDevice.address, PumpIOConstants.NONCE_INCREMENT) + + // Wait one second before the next attempt. The Combo does not seem to be able + // to handle an immediate reconnect attempt, and some Bluetooth stacks don't either. + delay(1000) + } + } + + if (!regularConnectionRequestAccepted) { + logger(LogLevel.ERROR) { "All attempts to request regular connection failed" } + throw ConnectionRequestIsNotBeingAcceptedException() + } + + // Initiate connection at the application layer. + logger(LogLevel.DEBUG) { "Initiating application layer connection" } + sendPacketWithResponse( + ApplicationLayer.createCTRLConnectPacket(), + ApplicationLayer.Command.CTRL_CONNECT_RESPONSE + ) + + // Explicitly switch to the initial mode. + switchMode(initialMode, runHeartbeat) + + logger(LogLevel.INFO) { "Pump IO connected" } + + _connectionState.value = ConnectionState.CONNECTED + } catch (e: CancellationException) { + disconnect() + throw e + } catch (t: Throwable) { + newScopeJob.cancelAndJoin() + _connectionState.value = ConnectionState.FAILED + throw t + } + } + + /** + * Disconnects from a pump. + * + * This terminates the connection that was set up by [connect]. + * + * If no connection is running, this does nothing. + * + * Calling this ensures an orderly IO shutdown and should not be + * omitted when shutting down an application. + */ + suspend fun disconnect() { + // Make sure that any function that is suspended by this + // barrier is woken up. Pass "false" to these functions + // to let them know that they need to abort any loop + // they might be running. + rtButtonConfirmationBarrier.trySend(false) + + stopCMDPingHeartbeat() + stopRTKeepAliveHeartbeat() + + val disconnectPacketInfo = ApplicationLayer.createCTRLDisconnectPacket() + logger(LogLevel.VERBOSE) { "Will send application layer disconnect packet: $disconnectPacketInfo" } + + transportLayerIO.stop( + disconnectPacketInfo.toTransportLayerPacketInfo(), + ::disconnectBTDeviceAndCatchExceptions + ) + + internalScope = null + internalScopeJob?.cancelAndJoin() + internalScopeJob = null + _currentModeFlow.value = null + onNewDisplayFrame(null) + + logger(LogLevel.DEBUG) { "Pump IO disconnected" } + } + + /** + * Reads the current datetime of the pump in COMMAND (CMD) mode. + * + * The current datetime is always given in localtime. + * + * @return The current datetime. + * @throws IllegalStateException if the pump is not in the comand + * mode or the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + * @throws ApplicationLayer.InvalidPayloadException if the size + * of a packet's payload does not match the expected size. + * @throws ComboIOException if IO with the pump fails. + */ + suspend fun readCMDDateTime(): LocalDateTime = runPumpIOCall("get current pump datetime", Mode.COMMAND) { + val packet = sendPacketWithResponse( + ApplicationLayer.createCMDReadDateTimePacket(), + ApplicationLayer.Command.CMD_READ_DATE_TIME_RESPONSE + ) + return@runPumpIOCall ApplicationLayer.parseCMDReadDateTimeResponsePacket(packet) + } + + /** + * Reads the current status of the pump in COMMAND (CMD) mode. + * + * The pump can be either in the stopped or in the running status. + * + * @return The current status. + * @throws IllegalStateException if the pump is not in the command + * mode or the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + * @throws ApplicationLayer.InvalidPayloadException if the size + * of a packet's payload does not match the expected size. + * @throws ComboIOException if IO with the pump fails. + */ + suspend fun readCMDPumpStatus(): ApplicationLayer.CMDPumpStatus = runPumpIOCall("get pump status", Mode.COMMAND) { + val packet = sendPacketWithResponse( + ApplicationLayer.createCMDReadPumpStatusPacket(), + ApplicationLayer.Command.CMD_READ_PUMP_STATUS_RESPONSE + ) + return@runPumpIOCall ApplicationLayer.parseCMDReadPumpStatusResponsePacket(packet) + } + + /** + * Reads the current error/warning status of the pump in COMMAND (CMD) mode. + * + * @return The current status. + * @throws IllegalStateException if the pump is not in the comand + * mode or the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + * @throws ApplicationLayer.InvalidPayloadException if the size + * of a packet's payload does not match the expected size. + * @throws ComboIOException if IO with the pump fails. + */ + suspend fun readCMDErrorWarningStatus(): ApplicationLayer.CMDErrorWarningStatus = + runPumpIOCall("get error/warning status", Mode.COMMAND) { + val packet = sendPacketWithResponse( + ApplicationLayer.createCMDReadErrorWarningStatusPacket(), + ApplicationLayer.Command.CMD_READ_ERROR_WARNING_STATUS_RESPONSE + ) + return@runPumpIOCall ApplicationLayer.parseCMDReadErrorWarningStatusResponsePacket(packet) + } + + /** + * Requests a CMD history delta. + * + * In the command mode, the Combo can provide a "history delta". + * This means that the user can get what events occurred since the + * last time a request was sent. Because this is essentially the + * difference between the current history state and the history + * state when the last request was sent, it is called a "delta". + * This also means that if a request is sent again, and no new + * event occurred in the meantime, the history delta will be empty + * (= it will have zero events recorded). It is _not_ possible + * to get the entire history with this function. + * + * The maximum amount of history block request is limited by the + * maxRequests argument. This is a safeguard in case the data + * keeps getting corrupted for some reason. Having a maximum + * guarantees that we can't get stuck in an infinite loop. + * + * @param maxRequests How many history block request we can + * maximally send. This must be at least 10. + * @return The history delta. + * @throws IllegalArgumentException if maxRequests is less than 10. + * @throws IllegalStateException if the pump is not in the comand + * mode or the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + * @throws ApplicationLayer.InvalidPayloadException if the size + * of a packet's payload does not match the expected size. + * @throws ApplicationLayer.PayloadDataCorruptionException if + * packet data integrity is compromised. + * @throws ApplicationLayer.InfiniteHistoryDataException if the + * call did not ever get a history block that marked an end + * to the history. + * @throws ComboIOException if IO with the pump fails. + */ + suspend fun getCMDHistoryDelta(maxRequests: Int = 40): List { + require(maxRequests >= 10) { "Maximum amount of requests must be at least 10; caller specified $maxRequests" } + + return runPumpIOCall("get history delta", Mode.COMMAND) { + val historyDelta = mutableListOf() + var reachedEnd = false + + // Keep requesting history blocks until we reach the end, + // and fill historyDelta with the events from each block, + // skipping those events whose IDs are unknown (this is + // taken care of by parseCMDReadHistoryBlockResponsePacket()). + for (requestNr in 1 until maxRequests) { + // Request the current history block from the Combo. + val packet = sendPacketWithResponse( + ApplicationLayer.createCMDReadHistoryBlockPacket(), + ApplicationLayer.Command.CMD_READ_HISTORY_BLOCK_RESPONSE + ) + + // Try to parse and validate the packet data. + val historyBlock = try { + ApplicationLayer.parseCMDReadHistoryBlockResponsePacket(packet) + } catch (t: Throwable) { + logger(LogLevel.ERROR) { + "Could not parse history block; data may have been corrupted; requesting the block again (throwable: $t)" + } + continue + } + + // Confirm this history block to let the Combo consider + // it processed. The Combo can then move on to the next + // history block. + sendPacketWithResponse( + ApplicationLayer.createCMDConfirmHistoryBlockPacket(), + ApplicationLayer.Command.CMD_CONFIRM_HISTORY_BLOCK_RESPONSE + ) + + historyDelta.addAll(historyBlock.events) + + // Check if there is a next history block to get. + // If not, we are done, and need to exit this loop. + if (!historyBlock.moreEventsAvailable || + (historyBlock.numRemainingEvents <= historyBlock.events.size) + ) { + reachedEnd = true + break + } + } + + if (!reachedEnd) + throw ApplicationLayer.InfiniteHistoryDataException( + "Did not reach an end of the history event list even after $maxRequests request(s)" + ) + + return@runPumpIOCall historyDelta + } + } + + /** + * Requests the current status of an ongoing bolus delivery. + * + * This is used for keeping track of the status of an ongoing bolus. + * If no bolus is ongoing, the return value's bolusType field is + * set to [ApplicationLayer.CMDBolusDeliveryState.NOT_DELIVERING]. + * + * @return The current status. + * @throws IllegalStateException if the pump is not in the comand + * mode or the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + * @throws ApplicationLayer.InvalidPayloadException if the size + * of a packet's payload does not match the expected size. + * @throws ApplicationLayer.DataCorruptionException if some of + * the fields in the status data received from the pump + * contain invalid values. + * @throws ComboIOException if IO with the pump fails. + */ + suspend fun getCMDCurrentBolusDeliveryStatus(): ApplicationLayer.CMDBolusDeliveryStatus = + runPumpIOCall("get current bolus delivery status", Mode.COMMAND) { + + val packet = sendPacketWithResponse( + ApplicationLayer.createCMDGetBolusStatusPacket(), + ApplicationLayer.Command.CMD_GET_BOLUS_STATUS_RESPONSE + ) + + return@runPumpIOCall ApplicationLayer.parseCMDGetBolusStatusResponsePacket(packet) + } + + /** + * Instructs the pump to deliver the specified standard bolus amount. + * + * As the name suggests, this function can only deliver a standard bolus, + * and no multi-wave or extended ones. In the future, additional functions + * may be written that can deliver those. + * + * The return value indicates whether the delivery was actually done. + * The delivery may not happen if for example the pump is currently + * stopped, or if it is already administering another bolus. It is + * recommended to keep track of the current bolus status by periodically + * calling [getCMDCurrentBolusDeliveryStatus]. + * + * @param bolusAmount Bolus amount to deliver. Note that this is given + * in 0.1 IU units, so for example, "57" means 5.7 IU. + * @return true if the bolus could be delivered, false otherwise. + * @throws IllegalStateException if the pump is not in the comand + * mode or the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + * @throws ApplicationLayer.InvalidPayloadException if the size + * of a packet's payload does not match the expected size. + * @throws ComboIOException if IO with the pump fails. + */ + suspend fun deliverCMDStandardBolus(bolusAmount: Int): Boolean = + runPumpIOCall("deliver standard bolus", Mode.COMMAND) { + + val packet = sendPacketWithResponse( + ApplicationLayer.createCMDDeliverBolusPacket(bolusAmount), + ApplicationLayer.Command.CMD_DELIVER_BOLUS_RESPONSE + ) + + return@runPumpIOCall ApplicationLayer.parseCMDDeliverBolusResponsePacket(packet) + } + + /** + * Cancels an ongoing bolus. + * + * @return true if the bolus was cancelled, false otherwise. + * If no bolus is ongoing, this returns false as well. + * @throws IllegalStateException if the pump is not in the command + * mode or the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + */ + suspend fun cancelCMDStandardBolus(): Boolean = runPumpIOCall("cancel bolus", Mode.COMMAND) { + // TODO: Test that this function does the expected thing + // when no bolus is actually ongoing. + val packet = sendPacketWithResponse( + ApplicationLayer.createCMDCancelBolusPacket(ApplicationLayer.CMDBolusType.STANDARD), + ApplicationLayer.Command.CMD_CANCEL_BOLUS_RESPONSE + ) + + return@runPumpIOCall ApplicationLayer.parseCMDCancelBolusResponsePacket(packet) + } + + /** + * Performs a short button press. + * + * This mimics the physical pressing of buttons for a short + * moment, followed by those buttons being released. + * + * This may not be called while a long button press is ongoing. + * It can only be called in the remote terminal (RT) mode. + * + * It is possible to short-press multiple buttons at the same + * time. This is necessary for moving back in the Combo's menu + * example. The buttons in the specified list are combined to + * form a code that is transmitted to the pump. + * + * @param buttons What button(s) to short-press. + * @throws IllegalArgumentException If the buttons list is empty. + * @throws IllegalStateException if a long button press is + * ongoing, the pump is not in the RT mode, or the + * pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + */ + suspend fun sendShortRTButtonPress(buttons: List) { + require(buttons.isNotEmpty()) { "Cannot send short RT button press since the specified buttons list is empty" } + check(currentLongRTPressJob == null) { "Cannot send short RT button press while a long RT button press is ongoing" } + + runPumpIOCall("send short RT button press", Mode.REMOTE_TERMINAL) { + val buttonCodes = getCombinedButtonCodes(buttons) + var delayBeforeNoButton = false + var ignoreNoButtonError = false + + try { + withContext(sequencedDispatcher) { + sendPacketWithoutResponse(ApplicationLayer.createRTButtonStatusPacket(buttonCodes, true)) + // Wait by "receiving" a value. We aren't actually interested + // in that value, just in receive() suspending this coroutine + // until the RT button was confirmed by the Combo. + rtButtonConfirmationBarrier.receive() + } + } catch (e: CancellationException) { + delayBeforeNoButton = true + ignoreNoButtonError = true + throw e + } catch (t: Throwable) { + delayBeforeNoButton = true + ignoreNoButtonError = true + logger(LogLevel.ERROR) { "Error thrown during short RT button press: ${t.stackTraceToString()}" } + throw t + } finally { + // Wait 200 milliseconds before sending NO_BUTTON if we reached this + // location due to an exception. That's because in that case we cannot + // know if the button confirmation barrier' receive() call was + // cancelled or not, and we shouldn't send button status packets + // to the Combo too quickly. + if (delayBeforeNoButton) + delay(TransportLayer.PACKET_SEND_INTERVAL_IN_MS) + + // Make sure we always attempt to send the NO_BUTTON + // code to finish the short button press, even if + // an exception is thrown. + try { + sendPacketWithoutResponse( + ApplicationLayer.createRTButtonStatusPacket(ApplicationLayer.RTButton.NO_BUTTON.id, true) + ) + } catch (t: Throwable) { + // The various IO operations in this function need to be viewed as being part + // of one big IO activity. That is, the RT button sending above _and_ the + // NO_BUTTON transmission here need to be seen as one single IO action. This + // IO action can fail at multiple stages. It can fail in the try block in the + // beginning of the runPumpIOCall when calling sendPacketWithoutResponse(). Or, + // it can fail in the second sendPacketWithoutResponse() call right above + // which sends NO_BUTTON to the Combo. + // If the first call fails, we ignore errors caused by the second call, since + // at this point, we'll forward the first call's exception anyway. In other + // words, in that situation, we already know something went wrong, so extra + // exceptions from the NO_BUTTON transmission are redundant. + // But if the first call succeeds, and instead, the _second_ call (the one which + // transmits NO_BUTTON to the Combo) fails, we do _not_ ignore that second call's + // exception, since that is the only one we've got at that point. If it were + // ignored, the user would never learn that something wrong happened with the IO. + // And this is important, since IO errors may require reconnecting to the Combo. + // To fix this, only ignore exceptions from the NO_BUTTON transmission if the + // ignoreNoButtonError variable is set to true. + if (ignoreNoButtonError) { + logger(LogLevel.DEBUG) { + "Ignoring error that was thrown while sending NO_BUTTON to end short button press; exception: $t" + } + } else { + logger(LogLevel.ERROR) { + "Error thrown while sending NO_BUTTON to end short button press; exception ${t.stackTraceToString()}" + } + throw t + } + } + } + } + } + + /** + * Performs a short button press. + * + * This overload is for convenience in case exactly one button + * is to be pressed. + */ + suspend fun sendShortRTButtonPress(button: ApplicationLayer.RTButton) = + sendShortRTButtonPress(listOf(button)) + + /** + * Starts a long RT button press, imitating buttons being held down. + * + * This can only be called in the remote terminal (RT) mode. + * + * If a long button press is already ongoing, this function + * does nothing. + * + * It is possible to long-press multiple buttons at the same + * time. This is necessary for moving back in the Combo's menu + * example. The buttons in the specified list are combined to + * form a code that is transmitted to the pump. + * + * Internally, a coroutine is launched that repeatedly transmits + * a confirmation command to the Combo that the buttons are still + * being held down. This loop continues until either the keepGoing + * predicate returns true (if that predicate isn't null) or until + * [stopLongRTButtonPress] is called. In both cases, a command is + * sent to the Combo to signal that the user "released" the buttons. + * + * If the [keepGoing] predicate is set, it is called before sending + * each confirmation command. This is particularly useful for + * aborting the loop at just the right time. In the Combo, this + * command triggers updates associated with the button(s) and the + * current screen. For example, in the bolus screen, if the UP + * button is pressed, such a command will cause the bolus amount + * to be incremented. Therefore, if the code in keepGoing waits + * for received [DisplayFrame] instances to check their contents + * before deciding whether to return true or false, it becomes + * possible to stop the bolus increment at precisely the correct + * moment (= when the target amount is reached). If however the + * confirmation commands were sent _too_ quickly, the user would + * see that the bolus amount is incremented even after "releasing" + * the button. + * + * @param buttons What button(s) to long-press. + * @param keepGoing Predicate for deciding whether to continue + * the internal loop. If this is set to null, the loop + * behaves as if this returned true all the time. + * @throws IllegalArgumentException If the buttons list is empty. + * @throws IllegalStateException if the pump is not in the RT mode + * or the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * in the packet receiver while this call is waiting for a packet or if + * an exception was thrown in the packet receiver prior to this call. + */ + suspend fun startLongRTButtonPress(buttons: List, keepGoing: (suspend () -> Boolean)? = null) { + require(buttons.isNotEmpty()) { "Cannot start long RT button press since the specified buttons list is empty" } + + if (currentLongRTPressJob != null) { + logger(LogLevel.DEBUG) { "Long RT button press job already running; ignoring redundant call" } + return + } + + runPumpIOCall("start long RT button press", Mode.REMOTE_TERMINAL) { + try { + issueLongRTButtonPressUpdate(buttons, keepGoing, pressing = true) + } catch (t: Throwable) { + stopLongRTButtonPress() + throw t + } + } + } + + /** + * Performs a long button press. + * + * This overload is for convenience in case exactly one button + * is to be pressed. + */ + suspend fun startLongRTButtonPress(button: ApplicationLayer.RTButton, keepGoing: (suspend () -> Boolean)? = null) = + startLongRTButtonPress(listOf(button), keepGoing) + + suspend fun stopLongRTButtonPress() { + if (currentLongRTPressJob == null) { + logger(LogLevel.DEBUG) { + "No long RT button press job running, and button press state is RELEASED; ignoring redundant call" + } + return + } + + runPumpIOCall("stop long RT button press", Mode.REMOTE_TERMINAL) { + issueLongRTButtonPressUpdate(listOf(ApplicationLayer.RTButton.NO_BUTTON), keepGoing = null, pressing = false) + } + } + + /** + * Waits for the coroutine that drives the long RT button press loop to finish. + * + * This finishes when either the keepAlive predicate in [startLongRTButtonPress] + * returns false or [stopLongRTButtonPress] is called. The former is the more + * common use case for this function. + * + * If no long RT button press is ongoing, this function does nothing, + * and just exits immediately. + * + * @throws Exception Exceptions that were thrown in the keepGoing callback + * that was passed to [startLongRTButtonPress]. + */ + suspend fun waitForLongRTButtonPressToFinish() { + // currentLongRTPressJob is set to null automatically when the job finishes + currentLongRTPressJob?.await() + } + + /** + * Switches the Combo to a different mode. + * + * The two valid modes are the remote terminal (RT) mode and the command mode. + * + * If an exception occurs, either disconnect, or try to repeat the mode switch. + * This is important to make sure the pump is in a known mode. + * + * The runHeartbeat argument functions just like the one in [connect]. + * It is necessary because mode switching stops any currently ongoing heartbeat. + * + * If the mode specified by newMode is the same as the current mode, + * this function does nothing. + * + * @param newMode Mode to switch to. + * @param runHeartbeat Whether or not to run the "heartbeat". + * @throws IllegalStateException if the pump is not connected. + * @throws TransportLayer.PacketReceiverException if an exception is thrown + * inside the packet receiver while this call is waiting for a packet + * or if an exception was thrown inside the receiver prior to this call. + * @throws ComboIOException if IO with the pump fails. + */ + suspend fun switchMode(newMode: Mode, runHeartbeat: Boolean = true) = withContext(NonCancellable) { + // This function is in a NonCancellable context to avoid undefined behavior + // if cancellation occurs during mode change. + + check(isIORunning()) { "Cannot switch mode because the pump is not connected" } + + if (_currentModeFlow.value == newMode) + return@withContext + + try { + logger(LogLevel.DEBUG) { "Switching mode from ${_currentModeFlow.value} to $newMode" } + + stopCMDPingHeartbeat() + stopRTKeepAliveHeartbeat() + + // Inform the callback that there's no frame available after the + // switch. This is particularly important when switching from the + // RT to the command mode. + onNewDisplayFrame(null) + + // Do the entire mode switch with the lock held and do it inside a NonCancellable + // context. We must make sure that _nothing_ else is communicated with the + // Combo during the mode switch. Using these blocks guarantees that. That's why + // we don't use sendPacketWithResponse() here and instead handle this manually. + sendPacketMutex.withLock { + withContext(NonCancellable) { + _currentModeFlow.value?.let { modeToDeactivate -> + logger(LogLevel.DEBUG) { "Deactivating current service" } + sendAppLayerPacket( + ApplicationLayer.createCTRLDeactivateServicePacket( + when (modeToDeactivate) { + Mode.REMOTE_TERMINAL -> ApplicationLayer.ServiceID.RT_MODE + Mode.COMMAND -> ApplicationLayer.ServiceID.COMMAND_MODE + } + ) + ) + logger(LogLevel.DEBUG) { "Sent CTRL_DEACTIVATE packet; waiting for CTRL_DEACTIVATE_SERVICE_RESPONSE packet" } + val receivedAppLayerPacket = transportLayerIO.receive(TransportLayer.Command.DATA).toAppLayerPacket() + if (receivedAppLayerPacket.command != ApplicationLayer.Command.CTRL_DEACTIVATE_SERVICE_RESPONSE) { + throw ApplicationLayer.IncorrectPacketException( + receivedAppLayerPacket, + ApplicationLayer.Command.CTRL_DEACTIVATE_SERVICE_RESPONSE + ) + } + } + + logger(LogLevel.DEBUG) { "Activating new service" } + sendAppLayerPacket( + ApplicationLayer.createCTRLActivateServicePacket( + when (newMode) { + Mode.REMOTE_TERMINAL -> ApplicationLayer.ServiceID.RT_MODE + Mode.COMMAND -> ApplicationLayer.ServiceID.COMMAND_MODE + } + ) + ) + logger(LogLevel.DEBUG) { "Sent CTRL_ACTIVATE packet; waiting for CTRL_ACTIVATE_SERVICE_RESPONSE packet" } + var receivedAppLayerPacket = transportLayerIO.receive(TransportLayer.Command.DATA).toAppLayerPacket() + + // XXX: In a few cases, we get this response instead. This seems to be a Combo bug - + // an extra CTRL_DEACTIVATE_SERVICE_RESPONSE packet is inserted before the actual + // response. The workaround appears to be to read and drop that extra response packet + // and then proceed as usual (since correct response packets follow that one). + if (receivedAppLayerPacket.command == ApplicationLayer.Command.CTRL_DEACTIVATE_SERVICE_RESPONSE) { + logger(LogLevel.INFO) { + "Got CTRL_DEACTIVATE_SERVICE_RESPONSE packet even though CTRL_ACTIVATE_SERVICE_RESPONSE was expected; " + + "suspected to be a Combo bug; trying to receive packet again as a workaround" + } + // Retry receiving. + receivedAppLayerPacket = transportLayerIO.receive(TransportLayer.Command.DATA).toAppLayerPacket() + } + + if (receivedAppLayerPacket.command != ApplicationLayer.Command.CTRL_ACTIVATE_SERVICE_RESPONSE) { + throw ApplicationLayer.IncorrectPacketException( + receivedAppLayerPacket, + ApplicationLayer.Command.CTRL_ACTIVATE_SERVICE_RESPONSE + ) + } + } + } + + _currentModeFlow.value = newMode + + if (runHeartbeat) { + logger(LogLevel.DEBUG) { "Resetting heartbeat" } + when (newMode) { + Mode.COMMAND -> startCMDPingHeartbeat() + Mode.REMOTE_TERMINAL -> startRTKeepAliveHeartbeat() + } + } + } catch (t: Throwable) { + _connectionState.value = ConnectionState.FAILED + throw t + } + } + + /************************************* + *** PRIVATE FUNCTIONS AND CLASSES *** + *************************************/ + + private fun isIORunning() = transportLayerIO.isIORunning() + + private fun newRtButtonConfirmationBarrier() = + Channel(capacity = Channel.CONFLATED) + + private fun getCombinedButtonCodes(buttons: List) = + buttons.fold(0) { codes, button -> codes or button.id } + + private fun toString(buttons: List) = buttons.joinToString(" ") { it.str } + + // The sendPacketWithResponse and sendPacketWithoutResponse calls + // are surrounded by a sendPacketMutex lock to prevent these functions + // from being called concurrently. This is essential, since the Combo + // cannot handle such concurrent calls. In particular, if a command + // that is sent to the Combo will cause the pump to respond with + // another command, we must make sure that we receive the response + // _before_ sending another command to the pump. (The main potential + // cause of concurrent send calls are the heartbeat coroutines.) + // + // Note that these functions use a coroutine mutex, not a "classical", + // thread level mutex. See kotlinx.coroutines.sync.Mutex for details. + // + // Furthermore, these use the NonCancellable context to prevent the + // prompt cancellation guarantee from cancelling any send attempts. + + private suspend fun sendPacketWithResponse( + tpLayerPacketInfo: TransportLayer.OutgoingPacketInfo, + expectedResponseCommand: TransportLayer.Command? = null + ): TransportLayer.Packet = sendPacketMutex.withLock { + return withContext(NonCancellable) { + transportLayerIO.send(tpLayerPacketInfo) + transportLayerIO.receive(expectedResponseCommand) + } + } + + private suspend fun sendPacketWithResponse( + appLayerPacketToSend: ApplicationLayer.Packet, + expectedResponseCommand: ApplicationLayer.Command? = null, + doRestartHeartbeat: Boolean = true + ): ApplicationLayer.Packet = sendPacketMutex.withLock { + return withContext(NonCancellable) { + if (doRestartHeartbeat) + restartHeartbeat() + + sendAppLayerPacket(appLayerPacketToSend) + + logger(LogLevel.VERBOSE) { + if (expectedResponseCommand == null) + "Waiting for application layer packet (will arrive in a transport layer DATA packet)" + else + "Waiting for application layer ${expectedResponseCommand.name} " + + "packet (will arrive in a transport layer DATA packet)" + } + + val receivedAppLayerPacket = transportLayerIO.receive(TransportLayer.Command.DATA).toAppLayerPacket() + + if ((expectedResponseCommand != null) && (receivedAppLayerPacket.command != expectedResponseCommand)) + throw ApplicationLayer.IncorrectPacketException(receivedAppLayerPacket, expectedResponseCommand) + + receivedAppLayerPacket + } + } + + private suspend fun sendPacketWithoutResponse( + tpLayerPacketInfo: TransportLayer.OutgoingPacketInfo + ) = sendPacketMutex.withLock { + withContext(NonCancellable) { + transportLayerIO.send(tpLayerPacketInfo) + } + } + + private suspend fun sendPacketWithoutResponse( + appLayerPacketToSend: ApplicationLayer.Packet, + doRestartHeartbeat: Boolean = true + ) = sendPacketMutex.withLock { + withContext(NonCancellable) { + if (doRestartHeartbeat) + restartHeartbeat() + sendAppLayerPacket(appLayerPacketToSend) + } + } + + private suspend fun sendAppLayerPacket(appLayerPacket: ApplicationLayer.Packet) { + // NOTE: This function does NOT lock a mutex and does NOT use + // NonCancellable. Make sure to set these up before calling this. + check(sendPacketMutex.isLocked) + + logger(LogLevel.VERBOSE) { + "Sending application layer packet via transport layer: $appLayerPacket" + } + + val outgoingPacketInfo = appLayerPacket.toTransportLayerPacketInfo() + + if (appLayerPacket.command.serviceID == ApplicationLayer.ServiceID.RT_MODE) { + if (outgoingPacketInfo.payload.size < (ApplicationLayer.PAYLOAD_BYTES_OFFSET + 2)) { + throw ApplicationLayer.InvalidPayloadException( + appLayerPacket, + "Cannot send application layer RT packet since there's no room in the payload for the RT sequence number" + ) + } + + logger(LogLevel.VERBOSE) { "Writing current RT sequence number $currentRTSequence into packet" } + + // The RT sequence is always stored in the + // first 2 bytes of an RT packet's payload. + // + // Also, we set the RT sequence in the outgoingPacketInfo, + // and not in appLayerPacket's payload, since the latter + // is a function argument, and modifying the payload of + // an outside value may lead to confusing behavior. + // By writing the RT sequence into outgoingPacketInfo + // instead, that change stays contained in here. + outgoingPacketInfo.payload[ApplicationLayer.PAYLOAD_BYTES_OFFSET + 0] = + ((currentRTSequence shr 0) and 0xFF).toByte() + outgoingPacketInfo.payload[ApplicationLayer.PAYLOAD_BYTES_OFFSET + 1] = + ((currentRTSequence shr 8) and 0xFF).toByte() + + // After using the RT sequence, increment it to + // make sure the next RT packet doesn't use the + // same RT sequence. + currentRTSequence++ + if (currentRTSequence > 65535) + currentRTSequence = 0 + } + + transportLayerIO.send(outgoingPacketInfo) + } + + private fun processReceivedPacket(tpLayerPacket: TransportLayer.Packet) = + if (tpLayerPacket.command == TransportLayer.Command.DATA) { + when (ApplicationLayer.extractAppLayerPacketCommand(tpLayerPacket)) { + ApplicationLayer.Command.CTRL_ACTIVATE_SERVICE_RESPONSE -> { + logger(LogLevel.DEBUG) { "New service was activated; resetting RT sequence number" } + currentRTSequence = 0 + TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET + } + + ApplicationLayer.Command.RT_DISPLAY -> { + processRTDisplayPayload( + ApplicationLayer.parseRTDisplayPacket(tpLayerPacket.toAppLayerPacket()) + ) + // Signal the arrival of the button confirmation. + // (Either RT_BUTTON_CONFIRMATION or RT_DISPLAY + // function as confirmations.) Transmit "true" + // to let the receivers know that everything + // is OK and that they don't need to abort. + rtButtonConfirmationBarrier.trySend(true) + TransportLayer.IO.ReceiverBehavior.DROP_PACKET + } + + ApplicationLayer.Command.RT_BUTTON_CONFIRMATION -> { + logger(LogLevel.VERBOSE) { "Got RT_BUTTON_CONFIRMATION packet from the Combo" } + // Signal the arrival of the button confirmation. + // (Either RT_BUTTON_CONFIRMATION or RT_DISPLAY + // function as confirmations.) Transmit "true" + // to let the receivers know that everything + // is OK and that they don't need to abort. + rtButtonConfirmationBarrier.trySend(true) + TransportLayer.IO.ReceiverBehavior.DROP_PACKET + } + + // We do not care about keep-alive packets from the Combo. + ApplicationLayer.Command.RT_KEEP_ALIVE -> { + logger(LogLevel.VERBOSE) { "Got RT_KEEP_ALIVE packet from the Combo; ignoring" } + TransportLayer.IO.ReceiverBehavior.DROP_PACKET + } + + // RT_AUDIO, RT_PAUSE, RT_RELEASE, RT_VIBRATION packets + // are purely for information. We just log them and + // otherwise ignore them. + + ApplicationLayer.Command.RT_AUDIO -> { + logger(LogLevel.VERBOSE) { + val audioType = ApplicationLayer.parseRTAudioPacket(tpLayerPacket.toAppLayerPacket()) + "Got RT_AUDIO packet with audio type ${audioType.toHexString(8)}; ignoring" + } + TransportLayer.IO.ReceiverBehavior.DROP_PACKET + } + + ApplicationLayer.Command.RT_PAUSE, + ApplicationLayer.Command.RT_RELEASE -> { + logger(LogLevel.VERBOSE) { + "Got ${ApplicationLayer.Command} packet with payload " + + "${tpLayerPacket.toAppLayerPacket().payload.toHexString()}; ignoring" + } + TransportLayer.IO.ReceiverBehavior.DROP_PACKET + } + + ApplicationLayer.Command.RT_VIBRATION -> { + logger(LogLevel.VERBOSE) { + val vibrationType = ApplicationLayer.parseRTVibrationPacket( + tpLayerPacket.toAppLayerPacket() + ) + "Got RT_VIBRATION packet with vibration type ${vibrationType.toHexString(8)}; ignoring" + } + TransportLayer.IO.ReceiverBehavior.DROP_PACKET + } + + // This is an information by the pump that something is wrong + // with the connection / with the service. This error is + // not recoverable. Throw an exception here to let the + // packet receiver fail. It will forward the exception to + // any ongoing send and receive calls. + ApplicationLayer.Command.CTRL_SERVICE_ERROR -> { + val appLayerPacket = tpLayerPacket.toAppLayerPacket() + val ctrlServiceError = ApplicationLayer.parseCTRLServiceErrorPacket(appLayerPacket) + logger(LogLevel.ERROR) { "Got CTRL_SERVICE_ERROR packet from the Combo; throwing exception" } + throw ApplicationLayer.ServiceErrorException(appLayerPacket, ctrlServiceError) + } + + else -> TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET + } + } else + TransportLayer.IO.ReceiverBehavior.FORWARD_PACKET + + private fun processRTDisplayPayload(rtDisplayPayload: ApplicationLayer.RTDisplayPayload) { + // Feed the payload to the display frame assembler to let it piece together + // frames and output them via the callback. + + try { + val displayFrame = displayFrameAssembler.processRTDisplayPayload( + rtDisplayPayload.index, + rtDisplayPayload.row, + rtDisplayPayload.rowBytes + ) + if (displayFrame != null) + onNewDisplayFrame(displayFrame) + } catch (t: Throwable) { + logger(LogLevel.ERROR) { "Could not process RT_DISPLAY payload: $t" } + throw t + } + } + + private fun isCMDPingHeartbeatRunning() = (cmdPingHeartbeatJob != null) + + private fun startCMDPingHeartbeat() { + if (isCMDPingHeartbeatRunning()) + return + + logger(LogLevel.VERBOSE) { "Starting background CMD ping heartbeat" } + + require(internalScope != null) + + cmdPingHeartbeatJob = internalScope!!.launch { + // In command mode, if no command has been sent to the Combo + // within about 1-1.5 seconds, the Combo terminates the + // connection, assuming that the client is gone. As a + // consequence, we have to send an CMD_PING packet + // to the Combo after a second. + // + // It is possible to prevent these packets from being + // sent when other commands were sent. To that end, if + // a command is to be sent, restartHeartbeat() can be + // called to effectively reset this timeout back to + // one second. If command-mode commands are sent frequently, + // this causes the timeout to be constantly reset, and the + // CMD_PING packet isn't sent until no more command-mode + // commands are sent. + // + // Also note that in here, we use sendPacketWithResponse() + // with doRestartHeartbeat set to false. The reason for this + // is that otherwise, the sendPacketWithResponse() function + // would internally call restartHeartbeat(), and doing that + // here would cause an infinite loop (and make no sense). + while (true) { + // *First* wait, and only *afterwards* send the + // CMD_PING packet. This order is important, since + // otherwise, an CMD_PING packet would be sent out + // immediately, and thus we would not have the timeout + // behavior described above. + delay(1000) + logger(LogLevel.VERBOSE) { "Transmitting CMD ping packet" } + try { + sendPacketWithResponse( + ApplicationLayer.createCMDPingPacket(), + ApplicationLayer.Command.CMD_PING_RESPONSE, + doRestartHeartbeat = false + ) + } catch (e: CancellationException) { + cmdPingHeartbeatJob = null + throw e + } catch (e: TransportLayer.PacketReceiverException) { + logger(LogLevel.ERROR) { + "Could not send CMD ping packet because packet receiver failed - stopping CMD ping heartbeat" + } + cmdPingHeartbeatJob = null + break + } catch (t: Throwable) { + logger(LogLevel.ERROR) { + "Error caught when attempting to transmit CMD ping packet - stopping CMD ping heartbeat" + } + logger(LogLevel.ERROR) { + "Error: ${t.stackTraceToString()}" + } + cmdPingHeartbeatJob = null + break + } + } + } + } + + private suspend fun stopCMDPingHeartbeat() { + if (!isCMDPingHeartbeatRunning()) + return + + logger(LogLevel.VERBOSE) { "Stopping background CMD ping heartbeat" } + + cmdPingHeartbeatJob?.cancelAndJoin() + cmdPingHeartbeatJob = null + + logger(LogLevel.VERBOSE) { "Background CMD ping heartbeat stopped" } + } + + private fun isRTKeepAliveHeartbeatRunning() = (rtKeepAliveHeartbeatJob != null) + + private fun startRTKeepAliveHeartbeat() { + if (isRTKeepAliveHeartbeatRunning()) + return + + logger(LogLevel.VERBOSE) { "Starting background RT keep-alive heartbeat" } + + require(internalScope != null) + + rtKeepAliveHeartbeatJob = internalScope!!.launch { + // In RT mode, if no RT command has been sent to the Combo + // within about 1-1.5 seconds, the Combo terminates the + // connection, assuming that the client is gone. As a + // consequence, we have to send an RT_KEEP_ALIVE packet + // to the Combo after a second. + // + // It is possible to prevent these packets from being + // sent when other RT commands were sent. To that end, if + // an RT command is to be sent, restartHeartbeat() can be + // called to effectively reset this timeout back to + // one second. If RT commands are sent frequently, this + // causes the timeout to be constantly reset, and the + // RT_KEEP_ALIVE packet isn't sent until no more RT + // commands are sent. + // + // Also note that in here, we use sendPacketWithoutResponse() + // with doRestartHeartbeat set to false. The reason for this + // is that otherwise, the sendPacketWithoutResponse() function + // would internally call restartHeartbeat(), and doing that + // here would cause an infinite loop (and make no sense). + while (true) { + // *First* wait, and only *afterwards* send the + // RT_KEEP_ALIVE packet. This order is important, since + // otherwise, an RT_KEEP_ALIVE packet would be sent out + // immediately, and thus we would not have the timeout + // behavior described above. + delay(1000) + logger(LogLevel.VERBOSE) { "Transmitting RT keep-alive packet" } + try { + sendPacketWithoutResponse( + ApplicationLayer.createRTKeepAlivePacket(), + doRestartHeartbeat = false + ) + } catch (e: CancellationException) { + rtKeepAliveHeartbeatJob = null + throw e + } catch (e: TransportLayer.PacketReceiverException) { + logger(LogLevel.ERROR) { + "Could not send RT keep-alive packet because packet receiver failed - stopping RT keep-alive heartbeat" + } + rtKeepAliveHeartbeatJob = null + break + } catch (t: Throwable) { + logger(LogLevel.ERROR) { + "Error caught when attempting to transmit RT keep-alive packet - stopping RT keep-alive heartbeat" + } + logger(LogLevel.ERROR) { + "Error: ${t.stackTraceToString()}" + } + rtKeepAliveHeartbeatJob = null + break + } + } + } + } + + private suspend fun stopRTKeepAliveHeartbeat() { + if (!isRTKeepAliveHeartbeatRunning()) + return + + logger(LogLevel.VERBOSE) { "Stopping background RT keep-alive heartbeat" } + + rtKeepAliveHeartbeatJob!!.cancelAndJoin() + rtKeepAliveHeartbeatJob = null + + logger(LogLevel.VERBOSE) { "Background RT keep-alive heartbeat stopped" } + } + + private suspend fun restartHeartbeat() { + when (currentModeFlow.value) { + Mode.REMOTE_TERMINAL -> { + if (isRTKeepAliveHeartbeatRunning()) { + stopRTKeepAliveHeartbeat() + startRTKeepAliveHeartbeat() + } + } + + Mode.COMMAND -> { + if (isCMDPingHeartbeatRunning()) { + stopCMDPingHeartbeat() + startCMDPingHeartbeat() + } + } + + // This happens during pairing and connecting, when CTRL packets + // are sent via sendPacketWithResponse() calls. These calls in + // turn call this function, but there is no defined heartbeat + // in the control mode (which is only used to set up pairing + // and a connection). Just don't do anything in that case. + null -> Unit + } + } + + private suspend fun issueLongRTButtonPressUpdate( + buttons: List, + keepGoing: (suspend () -> Boolean)?, + pressing: Boolean + ) { + if (!pressing) { + logger(LogLevel.DEBUG) { + "Releasing RTs button(s) ${toString(currentLongRTPressedButtons)}" + } + + // Set this to false to stop the long RT button press. + longRTPressLoopRunning = false + + // Wait for job completion by using await(). This will + // also re-throw any exceptions caught in that coroutine. + // In cases where connection to the pump fails, and no + // confirmation can be received anymore, this is still + // woken up, because in tha case, this channel is closed. + // See the transportLayerIO initialization above. + currentLongRTPressJob?.await() + + return + } + + currentLongRTPressedButtons = buttons + val buttonCodes = getCombinedButtonCodes(buttons) + longRTPressLoopRunning = true + + var delayBeforeNoButton = false + var ignoreNoButtonError = false + + currentLongRTPressJob = internalScope!!.async { + try { + // First time, we send the button status with + // the CHANGED status and with the codes for + // the pressed buttons. + var buttonStatusChanged = true + + while (longRTPressLoopRunning) { + // If there is a keepGoing predicate, call it _before_ sending + // a button status packet in case keepGoing() wishes to abort + // this loop already in its first iteration (for example, because + // a quantity that is shown on-screen is already correct). + if (keepGoing != null) { + try { + if (!keepGoing()) { + logger(LogLevel.DEBUG) { "Aborting long RT button press flow" } + break + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logger(LogLevel.DEBUG) { "keepGoing callback threw error: $t" } + throw t + } + } + + // Dummy tryReceive() call to clear out the barrier in case it isn't empty. + rtButtonConfirmationBarrier.tryReceive() + + logger(LogLevel.DEBUG) { + "Sending long RT button press; button(s) = ${toString(buttons)} status changed = $buttonStatusChanged" + } + + // Send the button status. This triggers an update on the Combo's + // remote terminal screen. For example, when pressing UP to + // increment a quantity, said quantity is incremented only + // after the Combo receives this status. + sendPacketWithoutResponse( + ApplicationLayer.createRTButtonStatusPacket(buttonCodes, buttonStatusChanged) + ) + + // Wait for the Combo to send us a button + // confirmation. We cannot send more button + // status commands until then. + logger(LogLevel.DEBUG) { "Waiting for button confirmation" } + val canContinue = rtButtonConfirmationBarrier.receive() + logger(LogLevel.DEBUG) { "Got button confirmation; canContinue = $canContinue" } + + if (!canContinue) + break + + // The next time we send the button status, we must + // send NOT_CHANGED to the Combo. + buttonStatusChanged = false + } + } catch (e: CancellationException) { + delayBeforeNoButton = true + ignoreNoButtonError = true + throw e + } catch (t: Throwable) { + delayBeforeNoButton = true + ignoreNoButtonError = true + logger(LogLevel.ERROR) { "Error thrown during long RT button press: ${t.stackTraceToString()}" } + throw t + } finally { + logger(LogLevel.DEBUG) { "Ending long RT button press by sending NO_BUTTON" } + try { + // Call sendPacketWithoutResponse() and delay() in a NonCancellable + // context to circumvent the prompt cancellation guarantee (it is + // undesirable here because we need to let the Combo know that we + // want to stop the long RT button press). + withContext(NonCancellable) { + // Wait 200 milliseconds before sending NO_BUTTON if we reached this + // location due to an exception. That's because in that case we cannot + // know if the button confirmation barrier' receive() call was + // cancelled or not, and we shouldn't send button status packets + // to the Combo too quickly. + if (delayBeforeNoButton) + delay(200L) + + sendPacketWithoutResponse( + ApplicationLayer.createRTButtonStatusPacket( + ApplicationLayer.RTButton.NO_BUTTON.id, + buttonStatusChanged = true + ) + ) + } + } catch (t: Throwable) { + // See the explanation inside sendShortRTButtonPress() + // for details why this logic is needed. + if (ignoreNoButtonError) { + logger(LogLevel.DEBUG) { + "Ignoring error that was thrown while sending NO_BUTTON to end long button press; exception: $t" + } + } else { + logger(LogLevel.ERROR) { + "Error thrown while sending NO_BUTTON to end long button press; exception ${t.stackTraceToString()}" + } + throw t + } + } + + currentLongRTPressJob = null + } + } + } + + private data class KeyResponseInfo(val pumpClientCipher: Cipher, val clientPumpCipher: Cipher, val keyResponseAddress: Byte) + + private fun processKeyResponsePacket(packet: TransportLayer.Packet, weakCipher: Cipher): KeyResponseInfo { + if (packet.payload.size != (CIPHER_KEY_SIZE * 2)) + throw TransportLayer.InvalidPayloadException(packet, "Expected ${CIPHER_KEY_SIZE * 2} bytes, got ${packet.payload.size}") + + val encryptedPCKey = ByteArray(CIPHER_KEY_SIZE) + val encryptedCPKey = ByteArray(CIPHER_KEY_SIZE) + + for (i in 0 until CIPHER_KEY_SIZE) { + encryptedPCKey[i] = packet.payload[i + 0] + encryptedCPKey[i] = packet.payload[i + CIPHER_KEY_SIZE] + } + + val pumpClientCipher = Cipher(weakCipher.decrypt(encryptedPCKey)) + val clientPumpCipher = Cipher(weakCipher.decrypt(encryptedCPKey)) + + // Note: Source and destination addresses are reversed, + // since they are set from the perspective of the pump. + val addressInt = packet.address.toPosInt() + val sourceAddress = addressInt and 0xF + val destinationAddress = (addressInt shr 4) and 0xF + val keyResponseAddress = ((sourceAddress shl 4) or destinationAddress).toByte() + + // We begin setting up the invariant pump data here. However, + // the pump state store cannot be initialized yet, because + // we do not yet know the pump ID. This initialization continues + // in processIDResponsePacket(). We fill cachedInvariantPumpData + // with the data we currently know. Later, it is filled again, + // and the remaining unknown data is also added. + + return KeyResponseInfo( + pumpClientCipher = pumpClientCipher, + clientPumpCipher = clientPumpCipher, + keyResponseAddress = keyResponseAddress + ) + } + + private fun processIDResponsePacket(packet: TransportLayer.Packet): String { + if (packet.payload.size != 17) + throw TransportLayer.InvalidPayloadException(packet, "Expected 17 bytes, got ${packet.payload.size}") + + val serverID = ((packet.payload[0].toPosLong() shl 0) or + (packet.payload[1].toPosLong() shl 8) or + (packet.payload[2].toPosLong() shl 16) or + (packet.payload[3].toPosLong() shl 24)) + + // The pump ID string can be up to 13 bytes long. If it + // is shorter, the unused bytes are filled with nullbytes. + val pumpIDStrBuilder = StringBuilder() + for (i in 0 until 13) { + val pumpIDByte = packet.payload[4 + i] + if (pumpIDByte == 0.toByte()) break + else pumpIDStrBuilder.append(pumpIDByte.toInt().toChar()) + } + val pumpID = pumpIDStrBuilder.toString() + + logger(LogLevel.DEBUG) { + "Received IDs: server ID: $serverID pump ID: $pumpID" + } + + return pumpID + } + + private suspend fun runPumpIOCall( + commandDesc: String, + expectedMode: Mode, + block: suspend () -> T + ): T { + check(isIORunning()) { + "Cannot $commandDesc because the pump is not connected" + } + check(_currentModeFlow.value == expectedMode) { + "Cannot $commandDesc while being in ${_currentModeFlow.value} mode" + } + + try { + return block() + } catch (t: Throwable) { + _connectionState.value = ConnectionState.FAILED + throw t + } + } + + private suspend fun disconnectBTDeviceAndCatchExceptions() { + // Disconnect the Bluetooth device and catch exceptions. + // disconnectBTDeviceAndCatchExceptions() is a function that gets called + // in catch and finally blocks, so propagating exceptions + // here would only complicate matters, because disconnect() + // gets called in catch blocks. + try { + // Use a NonCancellable context in case we are here because + // the performPairing or connectAsync coroutine got cancelled. + withContext(bluetoothDevice.ioDispatcher + NonCancellable) { + bluetoothDevice.disconnect() + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logger(LogLevel.ERROR) { + "Error occurred during Bluetooth device disconnect; not propagating; error: $t" + } + } + } +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PumpStateStore.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PumpStateStore.kt new file mode 100644 index 0000000000..3610baafdf --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/PumpStateStore.kt @@ -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 + + /** + * 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 +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Tbr.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Tbr.kt new file mode 100644 index 0000000000..bffc14f39f --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Tbr.kt @@ -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" } + } +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/TransportLayer.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/TransportLayer.kt new file mode 100644 index 0000000000..1e182bcf1b --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/TransportLayer.kt @@ -0,0 +1,1240 @@ +package info.nightscout.comboctl.base + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext + +private val logger = Logger.get("TransportLayer") + +/** + * This object contains types and constants related to the Combo transport layer. + * Included are functions for creating and parsing transport layer packets as well + * as an [IO] class that handles the transport layer IO and contains associated + * states. The [IO] class uses an internal coroutine that runs the "packet receiver", + * which is a loop where blocking [ComboIO.receive] calls are done to get packets + * from the Combo in the background. + */ +object TransportLayer { + /* Internal offset and sizes for packet IO. */ + + const val PACKET_HEADER_SIZE = 1 + 1 + 2 + 1 + NUM_NONCE_BYTES + + const val VERSION_BYTE_OFFSET = 0 + const val SEQ_REL_CMD_BYTE_OFFSET = 1 + const val PAYLOAD_LENGTH_BYTES_OFFSET = 2 + const val ADDRESS_BYTE_OFFSET = 4 + const val NONCE_BYTES_OFFSET = 5 + const val PAYLOAD_BYTES_OFFSET = NONCE_BYTES_OFFSET + NUM_NONCE_BYTES + + /** + * Maximum allowed size for transport layer packet payloads, in bytes. + */ + const val MAX_VALID_PAYLOAD_SIZE = 65535 + + /** + * Minimum interval between sent packets, in ms. + * See PacketSender.send() for more information. + */ + const val PACKET_SEND_INTERVAL_IN_MS = 200L + + /** + * Base class for transport layer exceptions. + * + * @param message The detail message. + * @param cause Throwable that further describes the cause of the error. + */ + open class ExceptionBase(message: String?, cause: Throwable? = null) : + ComboException(message, cause) + + /** + * Exception thrown when a transport layer packet arrives with an + * invalid application layer command ID. + * + * The packet is provided as bytes list since the [Packet] parser + * will refuse to parse a packet with an unknown ID. That's because + * an unknown ID may indicate that this is actually not packet data. + * + * @property commandID The invalid application layer command ID. + * @property packetBytes The bytes forming the invalid packet. + */ + class InvalidCommandIDException( + val commandID: Int, + val packetBytes: List + ) : ExceptionBase("Invalid/unknown transport layer packet command ID $commandID") + + /** + * Exception thrown when the arrived packet's command is not the one that was expected. + * + * @property packet Transport layer packet that arrived. + * @property expectedCommand The command that was expected in the packet. + */ + class IncorrectPacketException( + val packet: Packet, + val expectedCommand: Command + ) : ExceptionBase("Incorrect packet: expected ${expectedCommand.name} packet, got ${packet.command.name} one") + + /** + * Exception thrown when a packet fails verification. + * + * @property packet Transport layer packet that failed verification. + */ + class PacketVerificationException( + val packet: Packet + ) : ExceptionBase("Packet verification failed; packet details: $packet") + + /** + * Exception thrown when something is wrong with a transport layer packet's payload. + * + * @property packet Transport layer packet with the invalid payload. + * @property message Detail message. + */ + class InvalidPayloadException( + val packet: Packet, + message: String + ) : ExceptionBase(message) + + /** + * Exception thrown when the packet receiver fails. + * + * @param cause The throwable that was thrown in the packet + * receiver's loop, specifying want went wrong there. + */ + class PacketReceiverException(cause: Throwable) : ExceptionBase(cause.message, cause) + + /** + * Exception thrown when the Combo sends an ERROR_RESPONSE packet. + * + * These packets notify about errors in the communication between client + * and Combo at the transport layer. + * + * @property packet Transport layer packet with the error information. + * @property errorID ID of the error. + */ + class ErrorResponseException( + val packet: Packet, + val errorID: Int + ) : ExceptionBase("Error response by the Combo; error ID = 0x${errorID.toString(16)}") + + /** + * Valid commands for Combo transport layer packets. + */ + enum class Command( + val id: Int + ) { + // Pairing commands + REQUEST_PAIRING_CONNECTION(0x09), + PAIRING_CONNECTION_REQUEST_ACCEPTED(0x0A), + REQUEST_KEYS(0x0C), + GET_AVAILABLE_KEYS(0x0F), + KEY_RESPONSE(0x11), + REQUEST_ID(0x12), + ID_RESPONSE(0x14), + + // Regular commands - these require that pairing was performed + REQUEST_REGULAR_CONNECTION(0x17), + REGULAR_CONNECTION_REQUEST_ACCEPTED(0x18), + DISCONNECT(0x1B), + ACK_RESPONSE(0x05), + DATA(0x03), + ERROR_RESPONSE(0x06); + + companion object { + private val values = Command.values() + /** + * Converts an int to a command with the matching ID. + * + * @return Command, or null if the int is not a valid command IUD. + */ + fun fromInt(value: Int) = values.firstOrNull { it.id == value } + } + } + + // Utility function to be able to throw an exception in case of + // an invalid command ID in the Packet constructor below. + private fun checkedGetCommand(value: Int, bytes: List): TransportLayer.Command = + TransportLayer.Command.fromInt(value) ?: throw TransportLayer.InvalidCommandIDException(value, bytes) + + /** + * Class containing Combo transport layer packet data. + * + * Communication with the Combo uses packets as the basic unit. Each packet + * has a header, payload, and a machine authentication code (MAC). (Some initial + * pairing packets have a MAC made of nullbytes.) This class provides all + * properties of a packet as well as functions for converting from/to byte lists + * and for verifying / authenticating via MAC (and CRC for certain pairing packets). + * + * See "Transport layer packet structure" in combo-comm-spec.adoc for details. + * + * NOTE: Currently, it is not clear what "address" means. However, these values + * are checked by the Combo, so they must be set to valid values. + * + * Packets that are to be transmitted to the Combo are generated inside the + * [send] call out of [OutgoingPacketInfo] instances. + * + * @property command The command of this packet. + * @property version Byte containing version numbers. The upper 4 bit contain the + * major, the lower 4 bit the minor version number. + * In all observed packets, this was set to 0x10. + * @property sequenceBit The packet's sequence bit. + * @property reliabilityBit The packet's reliability bit. + * @property address Address byte. The upper 4 bit contain the source, the lower + * 4 bit the destination address. + * @property payload The packet's actual payload. Max valid size is 65535 bytes. + * @property machineAuthenticationCode Machine authentication code. Must be + * (re)calculated using [authenticate] if the packet uses MACs and + * it is being set up or its payload was modified. + * @throws IllegalArgumentException if the payload size exceeds + * [MAX_VALID_PAYLOAD_SIZE]. + */ + data class Packet( + val command: Command, + val version: Byte = 0x10, + val sequenceBit: Boolean = false, + val reliabilityBit: Boolean = false, + val address: Byte = 0, + val nonce: Nonce = Nonce.nullNonce(), + var payload: ArrayList = ArrayList(0), + var machineAuthenticationCode: MachineAuthCode = NullMachineAuthCode + ) { + init { + if (payload.size > MAX_VALID_PAYLOAD_SIZE) { + throw IllegalArgumentException( + "Payload size ${payload.size} exceeds allowed maximum of $MAX_VALID_PAYLOAD_SIZE bytes" + ) + } + } + + // This is a trick to avoid having to retrieve the payload size from + // the bytes more than once. The public variant of this constructor + // extracts the size, and then calls this one, passing the size as + // the second argument. + private constructor(bytes: List, payloadSize: Int) : this( + command = checkedGetCommand(bytes[SEQ_REL_CMD_BYTE_OFFSET].toPosInt() and 0x1F, bytes), + version = bytes[VERSION_BYTE_OFFSET], + sequenceBit = (bytes[SEQ_REL_CMD_BYTE_OFFSET].toPosInt() and 0x80) != 0, + reliabilityBit = (bytes[SEQ_REL_CMD_BYTE_OFFSET].toPosInt() and 0x20) != 0, + address = bytes[ADDRESS_BYTE_OFFSET], + nonce = Nonce(bytes.subList(NONCE_BYTES_OFFSET, NONCE_BYTES_OFFSET + NUM_NONCE_BYTES)), + payload = ArrayList(bytes.subList(PAYLOAD_BYTES_OFFSET, PAYLOAD_BYTES_OFFSET + payloadSize)), + machineAuthenticationCode = MachineAuthCode( + bytes.subList(PAYLOAD_BYTES_OFFSET + payloadSize, PAYLOAD_BYTES_OFFSET + payloadSize + NUM_MAC_BYTES) + ) + ) + + /** + * Deserializes a packet from a binary representation. + * + * This is needed for parsing packets coming from the Combo. However, + * packets coming from the Combo are framed, so it is important to + * make sure that the packet data was parsed using ComboFrameParser + * first. In other words, don't pass data coming through the Combo + * RFCOMM channel to this constructor directly. + * + * @param bytes Packet data to parse. + * @throws InvalidCommandIDException if the packet data + * contains a command ID that is unknown/unsupported. + */ + constructor(bytes: List) : + this(bytes, (bytes[PAYLOAD_LENGTH_BYTES_OFFSET + 1].toPosInt() shl 8) or bytes[PAYLOAD_LENGTH_BYTES_OFFSET + 0].toPosInt()) + + /** + * Serializes a packet to a binary representation suitable for framing and sending. + * + * This is needed for sending packets to the Combo. This function produces + * data that can be framed using [toComboFrame]. The resulting framed + * data can then be transmitted to the Combo through the RFCOMM channel. + * (Alternatively, the [FramedComboIO] class can be used to automatically + * frame outgoing packets). + * + * The withMAC and withPayload arguments exist mainly to be able to + * produce packet data that is suitable for generating CRCs and MACs. + * + * @param withMAC Include the MAC bytes into the packet data. + * @param withPayload Include the payload bytes into the packet data. + * @return The serialized packet data. + */ + fun toByteList(withMAC: Boolean = true, withPayload: Boolean = true): ArrayList { + val bytes = ArrayList(PACKET_HEADER_SIZE) + + bytes.add(version) + bytes.add(((if (sequenceBit) 0x80 else 0) + or (if (reliabilityBit) 0x20 else 0) + or command.id).toByte()) + bytes.add((payload.size and 0xFF).toByte()) + bytes.add(((payload.size shr 8) and 0xFF).toByte()) + bytes.add(address) + + bytes.addAll(nonce.asSequence()) + + if (withPayload) + bytes.addAll(payload) + + if (withMAC) + bytes.addAll(machineAuthenticationCode.asSequence()) + + return bytes + } + + /** + * Computes a 2-byte payload that is the CRC-16-MCRF4XX checksum of the packet header. + * + * This erases any previously existing payload + * and resets the payload size to 2 bytes. + */ + fun computeCRC16Payload() { + payload = byteArrayListOfInts(0, 0) + val headerData = toByteList(withMAC = false, withPayload = false) + val calculatedCRC16 = calculateCRC16MCRF4XX(headerData) + payload[0] = (calculatedCRC16 and 0xFF).toByte() + payload[1] = ((calculatedCRC16 shr 8) and 0xFF).toByte() + } + + /** + * Verifies the packet header data by computing its CRC-16-MCRF4XX checksum and + * comparing it against the one present as the packet's 2-byte payload. + * + * @return true if the CRC check succeeds, false if it fails (indicating data corruption). + * @throws InvalidPayloadException if the payload is not made of 2 bytes. + */ + fun verifyCRC16Payload(): Boolean { + if (payload.size != 2) { + throw InvalidPayloadException( + this, + "Invalid CRC16 payload: CRC16 payload has 2 bytes, this packet has ${payload.size}" + ) + } + val headerData = toByteList(withMAC = false, withPayload = false) + val calculatedCRC16 = calculateCRC16MCRF4XX(headerData) + return (payload[0] == (calculatedCRC16 and 0xFF).toByte()) && + (payload[1] == ((calculatedCRC16 shr 8) and 0xFF).toByte()) + } + + /** + * Authenticates the packet using the given cipher. + * + * Authentication means that a MAC is generated for this packet and stored + * in the packet's last 8 bytes. The MAC is generated using the given cipher. + * + * @param cipher Cipher to use for generating the MAC. + */ + fun authenticate(cipher: Cipher) { + machineAuthenticationCode = calculateMAC(cipher) + } + + /** + * Verify the authenticity of the packet using the MAC. + * + * @param cipher Cipher to use for the verification. + * @return true if the packet is found to be valid, false otherwise + * (indicating data corruption). + */ + fun verifyAuthentication(cipher: Cipher): Boolean = + calculateMAC(cipher) == machineAuthenticationCode + + // This computes the MAC using Two-Fish and a modified RFC3610 CCM authentication + // process. See "Packet authentication" in combo-comm-spec.adoc for details. + private fun calculateMAC(cipher: Cipher): MachineAuthCode { + val macBytes = ArrayList(NUM_MAC_BYTES) + var block = ByteArray(CIPHER_BLOCK_SIZE) + + // Set up B_0. + block[0] = 0x79 + for (i in 0 until NUM_NONCE_BYTES) block[i + 1] = nonce[i] + block[14] = 0x00 + block[15] = 0x00 + + // Produce X_1 out of B_0. + block = cipher.encrypt(block) + + val packetData = toByteList(withMAC = false, withPayload = true) + val numDataBlocks = packetData.size / CIPHER_BLOCK_SIZE + + // Repeatedly produce X_i+1 out of X_i and B_i. + // X_i is the current block value, B_i is the + // data from packetData that is being accessed + // inside the loop. + for (dataBlockNr in 0 until numDataBlocks) { + for (i in 0 until CIPHER_BLOCK_SIZE) { + val a: Int = block[i].toPosInt() + val b: Int = packetData[dataBlockNr * CIPHER_BLOCK_SIZE + i].toPosInt() + block[i] = (a xor b).toByte() + } + + block = cipher.encrypt(block) + } + + // Handle the last block, and apply padding if needed. + val remainingDataBytes = packetData.size - numDataBlocks * CIPHER_BLOCK_SIZE + if (remainingDataBytes > 0) { + for (i in 0 until remainingDataBytes) { + val a: Int = block[i].toPosInt() + val b: Int = packetData[packetData.size - remainingDataBytes + i].toPosInt() + block[i] = (a xor b).toByte() + } + + val paddingValue = 16 - remainingDataBytes + + for (i in remainingDataBytes until CIPHER_BLOCK_SIZE) + block[i] = ((block[i].toPosInt()) xor paddingValue).toByte() + + block = cipher.encrypt(block) + } + + // Here, the non-standard portion of the authentication starts. + + // Produce the "U" value. + for (i in 0 until NUM_MAC_BYTES) + macBytes.add(block[i]) + + // Produce the new B_0. + block[0] = 0x41 + for (i in 0 until NUM_NONCE_BYTES) block[i + 1] = nonce[i] + block[14] = 0x00 + block[15] = 0x00 + + // Produce X_1 out of the new B_0. + block = cipher.encrypt(block) + + // Compute the final MAC out of U and the + // first 8 bytes of X_1 XORed together. + for (i in 0 until NUM_MAC_BYTES) + macBytes[i] = ((macBytes[i].toPosInt()) xor (block[i].toPosInt())).toByte() + + return MachineAuthCode(macBytes) + } + + override fun toString() = + "version: ${version.toHexString(2)}" + + " command: ${command.name}" + + " sequence bit: $sequenceBit" + + " reliability bit: $reliabilityBit" + + " address: ${address.toHexString(2)}" + + " nonce: $nonce" + + " MAC: $machineAuthenticationCode" + + " payload: ${payload.size} byte(s): [${payload.toHexString()}]" + } + + /** + * Data class with information about a packet that will go out to the Combo. + * + * This is essentially a template for a [Packet] instance that will then + * be sent to the Combo. Compared to [Packet], this is missing several fields + * of the header in [Packet], most notably the Tx nonce and MAC authentication. + * These fields would require access to internal [IO] states in order to be + * computed, and that state is changed after every packet send operation, + * so we instead pass instances of this class to [IO] to send. [IO] then + * internally produces a [Packet] out of this along with these extra fields. + * + * @property command Command of the outgoing packet. + * @property payload The outgoing packet's payload. Empty payloads are valid + * (depending on the particular command). + * @property reliable This is set to true if the packet's reliability bit + * shall be set to 1. + * @property sequenceBitOverride If null, the [IO] class will use its + * normal sequence bit logic, otherwise it will set the outgoing + * packet's bit to this value. + */ + data class OutgoingPacketInfo( + val command: Command, + val payload: ArrayList = ArrayList(), + val reliable: Boolean = false, + val sequenceBitOverride: Boolean? = null + ) { + override fun toString() = + "command: ${command.name}" + + " reliable: $reliable" + + " sequenceBitOverride: ${sequenceBitOverride ?: ""}" + + " payload: ${payload.size} byte(s): [${payload.toHexString()}]" + } + + /** + * Creates a REQUEST_PAIRING_CONNECTION OutgoingPacketInfo instance. + * + * This is exclusively used during the pairing process. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet info. + */ + fun createRequestPairingConnectionPacketInfo() = + OutgoingPacketInfo(command = Command.REQUEST_PAIRING_CONNECTION) + + /** + * Creates a REQUEST_KEYS OutgoingPacketInfo instance. + * + * This is exclusively used during the pairing process. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet info. + */ + fun createRequestKeysPacketInfo() = + OutgoingPacketInfo(command = Command.REQUEST_KEYS) + + /** + * Creates a GET_AVAILABLE_KEYS OutgoingPacketInfo instance. + * + * This is exclusively used during the pairing process. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet info. + */ + fun createGetAvailableKeysPacketInfo() = + OutgoingPacketInfo(command = Command.GET_AVAILABLE_KEYS) + + /** + * Creates a REQUEST_ID OutgoingPacketInfo instance. + * + * This is exclusively used during the pairing process. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @param bluetoothFriendlyName Bluetooth friendly name to use in the request. + * Maximum length is 13 characters. + * See the Bluetooth specification, Vol. 3 part C section 3.2.2 + * for details about Bluetooth friendly names. + * @return The produced packet info. + */ + fun createRequestIDPacketInfo(bluetoothFriendlyName: String): OutgoingPacketInfo { + val btFriendlyNameBytes = bluetoothFriendlyName.encodeToByteArray() + val numBTFriendlyNameBytes = kotlin.math.min(btFriendlyNameBytes.size, 13) + + val payload = ArrayList(17) + + payload.add(((Constants.CLIENT_SOFTWARE_VERSION shr 0) and 0xFF).toByte()) + payload.add(((Constants.CLIENT_SOFTWARE_VERSION shr 8) and 0xFF).toByte()) + payload.add(((Constants.CLIENT_SOFTWARE_VERSION shr 16) and 0xFF).toByte()) + payload.add(((Constants.CLIENT_SOFTWARE_VERSION shr 24) and 0xFF).toByte()) + + // If the BT friendly name is shorter than 13 bytes, + // the rest must be set to zero. + for (i in 0 until numBTFriendlyNameBytes) payload.add(btFriendlyNameBytes[i]) + for (i in numBTFriendlyNameBytes until 13) payload.add(0.toByte()) + + return OutgoingPacketInfo( + command = Command.REQUEST_ID, + payload = payload + ) + } + + /** + * Creates a REQUEST_REGULAR_CONNECTION OutgoingPacketInfo instance. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @return The produced packet info. + */ + fun createRequestRegularConnectionPacketInfo() = + OutgoingPacketInfo(command = Command.REQUEST_REGULAR_CONNECTION) + + /** + * Creates an ACK_RESPONSE OutgoingPacketInfo instance. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @param sequenceBit Sequence bit to set in the ACK_RESPONSE packet. + * @return The produced packet info. + */ + fun createAckResponsePacketInfo(sequenceBit: Boolean) = + OutgoingPacketInfo( + command = Command.ACK_RESPONSE, + sequenceBitOverride = sequenceBit + ) + + /** + * Creates a DATA OutgoingPacketInfo instance. + * + * See the combo-comm-spec.adoc file for details about this packet. + * + * @param reliabilityBit Reliability bit to set in the DATA packet. + * @param payload Payload to assign to the DATA packet. + * @return The produced packet info. + */ + fun createDataPacketInfo(reliabilityBit: Boolean, payload: ArrayList) = + OutgoingPacketInfo( + command = Command.DATA, + payload = payload, + reliable = reliabilityBit + ) + + /** + * Class for performing IO operations at the transport layer level. + * + * This takes care of transport layer details such as alternating + * sequence flags and packet authentications & verifications. + * Packet reception is handled in an internal coroutine, which + * run with a "sequenced dispatcher". That's a coroutine dispatcher + * which never runs more than one task at the same time, even if + * it uses an underlying thread pool, thus disallowing parallelism. + * Send operations are also run in that dispatcher. This is done + * for thread safety reasons. + * + * This class is used for both pairing with a pump and for regular + * communication with a pump. However, this class assumes that the + * pump data from the [pumpStateStore] is invariant. Since the + * pairing process sets this data up, that assumption would be + * violated if the same [IO] instance were used for both pairing + * and for regular connection. For this reason, when pairing with + * a pump, create a dedicatd [IO] instance for the pairing, and + * afterwards, discard it. + * + * This is not typically used by external callers. Instead, this is + * meant for other ComboCtl classes. + * + * Functions from this class must not be called simultaneously + * across threads. The Combo does not support concurrent read and + * write operations. + * + * @param pumpStateStore Pump state store to use. + * @param pumpAddress Bluetooth address of the pump. Used for + * accessing the pump state store. + * @param comboIO Combo IO object to use for sending/receiving data. + * @param onPacketReceiverException Callback meant for custom cleanup in case + * a [PacketReceiverException] is thrown inside the packet receiver. + */ + class IO( + private val pumpStateStore: PumpStateStore, + private val pumpAddress: BluetoothAddress, + private val comboIO: ComboIO, + private val onPacketReceiverException: (e: PacketReceiverException) -> Unit + ) { + // Invariant pump data from the state store. Retrieved + // and cached into this instace when start() is called. + private var cachedInvariantPumpData = InvariantPumpData.nullData() + + // The current transport layer sequence flag, toggled + // for each reliable packet. + private var currentSequenceFlag = false + + // Timestamp (in ms) of the last time a packet was sent. + // Used for throttling the output. + private var lastSentPacketTimestamp: Long? = null + + // The last PacketReceiverException encountered in the + // packet receiver coroutine. + private var lastPacketReceiverException: PacketReceiverException? = null + // Job instance representing the packet receiver coroutine. + private var packetReceiverJob: Job? = null + // Channel used for transporting the received packets from + // the packet receiver to the receive() function. + private var packetReceiverChannel = Channel( + capacity = Channel.UNLIMITED, + onBufferOverflow = BufferOverflow.SUSPEND + ) + + /** + * Return value used for [start] onPacketReceived callbacks. + * + * This tells the packet receiver what to do with that packet - + * drop it, or forward it through the packet receiver channel + * so that [receive] can get them. + */ + enum class ReceiverBehavior { + FORWARD_PACKET, + DROP_PACKET + } + + /** + * Manually set the internal cached invariant pump data. + * + * This is only useful during pairing, when the contents of the + * KEY_RESPONSE packet is needed for correctly producing outgoing + * REQUEST_ID packet (and others). During regular connections, + * this must not be used - the data is instead retrieved from + * [pumpStateStore]. + * + * @param newInvariantPumpData New data to use as the cached pump data. + */ + fun setManualInvariantPumpData(newInvariantPumpData: InvariantPumpData) { + cachedInvariantPumpData = newInvariantPumpData + } + + /** + * Starts IO activities. + * + * This must be called before [send] and [receive] can be used. + * + * To receive packets in the background from the Combo, this starts + * an internal coroutine that runs in the [packetReceiverScope]. + * That scope's associated dispatcher is overwritten; a different + * dispatcher is used instead (one that never executes tasks + * simultaneously, on several threads). The "packet receiver" + * is that coroutine. It runs a loop that keeps receiving packets. + * The [onPacketReceived] callback defines if the packet receiver + * should drop the packet or forward it through an internal channel + * to receive() calls. + * + * @param packetReceiverScope [CoroutineScope] to run the packet + * receiver coroutine in. + * @param onPacketReceived Callback that defines if the packet + * needs to be dropped or forwarded. + */ + fun start( + packetReceiverScope: CoroutineScope, + onPacketReceived: (packet: Packet) -> ReceiverBehavior + ) { + check(packetReceiverJob == null) { "IO already started" } + + // Override the scope's existing dispatcher with the + // sequencedDispatcher to ensure our IO operations never + // run in parallel and to prevent internal states to be + // accessed in parallel by multiple threads. + startInternal(packetReceiverScope + sequencedDispatcher, onPacketReceived) + } + + /** + * Stops ongoing IO. + * + * If no IO is ongoing, this does nothing. If there is IO ongoing, + * this suspends the calling coroutine until all IO activity ceases. + * + * The packet receiver that was spawned in [start] is stopped + * and destroyed by this function. + * + * [disconnectDeviceCallback] is necessary to unblock the packet + * receiver. The IO underlying comboIO may not be cancellable by + * anything other than by closing a socket for example. For this + * reason, this callback exists. That callback can then close such + * a socket, thus unblocking IO calls. + * + * @param disconnectPacketInfo Information about the final packet + * to generate and send to the Combo as part of an orderly + * shutdown. If set to null, no packet will be sent. + * @param disconnectDeviceCallback Callback to be invoked during + * the shutdown procedure. + */ + suspend fun stop(disconnectPacketInfo: OutgoingPacketInfo? = null, disconnectDeviceCallback: suspend () -> Unit = { }) { + if (!isIORunning()) { + // Invoke the disconnectDeviceCallback even if IO isn't actually running. + // That's because the callback may be needed to for example close a socket + // and abort an ongoing connect attempt. + try { + disconnectDeviceCallback() + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logger(LogLevel.WARN) { "Error thrown in disconnectDeviceCallback: $t ; swallowing this throwable" } + // We are tearing down IO already, so we swallow throwables here. + } + return + } + + try { + if (disconnectPacketInfo != null) { + val packet = produceOutgoingPacket(disconnectPacketInfo) + + // We use comboIO.send() directly instead of send() + // here, since we need to send the disconnect packet + // even if the packet receiver failed. + logger(LogLevel.VERBOSE) { "Sending transport layer packet: $packet" } + comboIO.send(packet.toByteList()) + logger(LogLevel.VERBOSE) { "Packet sent" } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + // Swallow throwable since we are anyway already disconnecting. + logger(LogLevel.ERROR) { "Caught error while sending disconnect packet: $t" } + } finally { + logger(LogLevel.DEBUG) { "Disconnecting device" } + + // Do device specific disconnect here to unblock any ongoing + // blocking receive / send calls. Normally, this is not + // necessary, since the Combo terminates the connection once + // the disconnect packet gets transmitted. But in case the + // Combo doesn't terminate the connection (for example, because + // the packet never arrived, or because Bluetooth failed), we + // still have to make sure that the blocking calls are + // unblocked right away. + try { + disconnectDeviceCallback() + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logger(LogLevel.WARN) { "Error thrown in disconnectDeviceCallback: $t ; swallowing this throwable" } + // We are tearing down IO already, so we swallow throwables here. + } + + logger(LogLevel.DEBUG) { "Stopping packet receiver" } + + try { + packetReceiverJob?.cancelAndJoin() + } catch (e: ComboException) { + logger(LogLevel.WARN) { "Exception while cancelling IO: $e ; swallowing this exception" } + // We are tearing down IO already, so we swallow exceptions here. + } + packetReceiverJob = null + + logger(LogLevel.DEBUG) { "Transport layer IO stopped" } + } + } + + /** Returns true if IO is ongoing (due to a [startIO] call), false otherwise. */ + fun isIORunning() = (packetReceiverJob != null) + + /** + * Generates a packet out of the given [packetInfo] and sends it to the Combo. + * + * [start] must have been called prior to calling this function. + * + * This function suspends the calling coroutine until the send operation + * is complete, or an exception is thrown. + * + * @param packetInfo Information about the packet to generate and send. + * @throws IllegalStateException if IO is not running or if it has failed. + * @throws PacketReceiverException if an exception was thrown inside the + * packet receiver prior to this call. + * @throws ComboIOException if sending fails due to an underlying IO error. + * @throws PumpStateStoreAccessException if accessing the current Tx + * nonce in the pump state store failed while preparing the packet + * for sending. + */ + suspend fun send(packetInfo: OutgoingPacketInfo) { + check(isIORunning()) { + "Attempted to send packet even though IO is not running" + } + + if (!receiverIsOK()) { + lastPacketReceiverException?.let { + throw it + } ?: throw Error("Packet receiver channel failed for unknown reason") + } + + sendInternal(packetInfo) + } + + /** + * Receives transport layer packets from the Combo. + * + * The actual receiving is done by the internal packet receiver. + * See [start] for details about this. + * + * [start] must have been called prior to calling this function. + * + * This function suspends the calling coroutine until the receive + * operation is complete, or an exception is thrown. + * + * Optionally, this function can check if a received packet has a + * correct command. This is useful if during a sequence a specific + * command is expected. This is done if expectedCommand is non-null. + * + * @param expectedCommand Optional TransportLayerIO Packet command to check for. + * @throws IllegalStateException if IO is not running or if it has failed. + * @throws PacketReceiverException if an exception was thrown inside the + * packet receiver prior to this call, or if an exception is thrown + * inside the packet receiver while this call is waiting for a packet. + * @throws IncorrectPacketException if expectedCommand is non-null and + * the received packet's command does not match expectedCommand. + */ + suspend fun receive(expectedCommand: Command? = null): TransportLayer.Packet { + // In here, we mainly listen to the packetReceiverChannel + // for incoming packets from the packet receiver coroutine. + // The actual reception takes place there. startInternal() + // contains that receiver's code. + + check(isIORunning()) { + "Attempted to receive packet even though IO is not running" + } + + if (!receiverIsOK()) { + lastPacketReceiverException?.let { + throw it + } ?: throw Error("Packet receiver channel failed for unknown reason") + } + + logger(LogLevel.VERBOSE) { + if (expectedCommand == null) + "Waiting for transport layer packet" + else + "Waiting for transport layer ${expectedCommand.name} packet" + } + + val packet = packetReceiverChannel.receive() + + // Check if the packet's command is correct (if required). + if ((expectedCommand != null) && (packet.command != expectedCommand)) + throw IncorrectPacketException(packet, expectedCommand) + + logger(LogLevel.VERBOSE) { "Received packet: $packet" } + + return packet + } + + /************************************* + *** PRIVATE FUNCTIONS AND CLASSES *** + *************************************/ + + private fun receiverIsOK() = packetReceiverJob?.isActive ?: false + + private fun startInternal( + packetReceiverScope: CoroutineScope, + onPacketReceived: (packet: Packet) -> ReceiverBehavior + ) { + cachedInvariantPumpData = if (pumpStateStore.hasPumpState(pumpAddress)) + pumpStateStore.getInvariantPumpData(pumpAddress) + else + InvariantPumpData.nullData() + + currentSequenceFlag = false + lastSentPacketTimestamp = null + lastPacketReceiverException = null + + reopenPacketReceiverChannel() + + packetReceiverJob = packetReceiverScope.launch { + while (true) { + try { + receiveAndPreprocessPacket()?.let { packet -> + if (onPacketReceived(packet) == ReceiverBehavior.FORWARD_PACKET) + packetReceiverChannel.send(packet) + } + } catch (t: Throwable) { + val packetReceiverException = PacketReceiverException(t) + lastPacketReceiverException = packetReceiverException + packetReceiverChannel.close(packetReceiverException) + onPacketReceiverException(packetReceiverException) + + when (t) { + // Pass through CancellationException to make sure coroutine + // cancellation is not broken by this try-catch block. + is CancellationException -> throw t + is ComboException -> { + logger(LogLevel.DEBUG) { "Caught Combo exception in receive loop: $t" } + logger(LogLevel.DEBUG) { "Combo exception stacktrace: ${t.stackTraceToString()}" } + break + } + else -> { + logger(LogLevel.ERROR) { + "FATAL: Unhandled throwable observed in receiver loop: ${t.stackTraceToString()}" + } + break + } + } + } + } + } + } + + private suspend fun sendInternal(packetInfo: OutgoingPacketInfo) = withContext(sequencedDispatcher) { + // It is important to throttle the output to not overload + // the Combo's packet ring buffer. Otherwise, old packets + // get overwritten by new ones, and the Combo begins to + // report errors. Empirically, a waiting period of around + // 150-200 ms seems to work well to avoid this. Here, we + // check how much time has passed since the last packet + // transmission. If less than 200 ms have passed, we wait + // with delay() until a total of 200 ms elapsed. + + val elapsedTime = getElapsedTimeInMs() + + if (lastSentPacketTimestamp != null) { + val timePassed = elapsedTime - lastSentPacketTimestamp!! + if (timePassed < PACKET_SEND_INTERVAL_IN_MS) { + val waitPeriod = PACKET_SEND_INTERVAL_IN_MS - timePassed + logger(LogLevel.VERBOSE) { "Waiting for $waitPeriod ms until a packet can be sent" } + delay(waitPeriod) + } + } + + lastSentPacketTimestamp = elapsedTime + + // Proceed with sending the packet. + // Do this in a NonCancellable context to prevent cancellations + // from happening between produceOutgoingPacket() and send(). + // This is because produceOutgoingPacket() updates internal + // states in a way that the pump expects (specifically the + // reliability bit and sequence flag updates). If we allow + // cancellations in between the functions here, we may not + // be aware post-cancellation what the state at the pump is, + // causing undefined behavior. The risk of getting stuck here + // due to cancellation being disabled is mitigated, since the + // only function that can suspend here is send(). That function + // cannot be cancelled in the usual manner, since it uses + // blocking system IO. But, such IO layers typically have + // some sort of close() function to close the socket or + // tunnel etc., and that function immediately aborts any + // blocking send/receive operations. + withContext(NonCancellable) { + val packet = produceOutgoingPacket(packetInfo) + + logger(LogLevel.VERBOSE) { "Sending transport layer packet: $packet" } + comboIO.send(packet.toByteList()) + logger(LogLevel.VERBOSE) { "Packet sent" } + } + } + + private suspend fun receiveAndPreprocessPacket(): Packet? { + lateinit var packet: Packet + + try { + packet = Packet(comboIO.receive()) + } catch (e: InvalidCommandIDException) { + logger(LogLevel.WARN) { + "Skipping packet with invalid/unknown ID ${e.commandID}; ${e.packetBytes.size} packet byte(s): ${e.packetBytes.toHexString()}" + } + return null + } + + logger(LogLevel.VERBOSE) { "Incoming transport layer packet: $packet" } + + // Authenticate the packet. A special exemption applies to the + // KEY_RESPONSE and ID_RESPONSE packets. These need to be manually + // verified by callers during the pairing process (which isn't + // handled by the IO class), since they are part of the + // authentication key setup. + // TODO: Also verify packets with no MAC but with a CRC checksum. + + val packetIsValid = when (packet.command) { + Command.REGULAR_CONNECTION_REQUEST_ACCEPTED, + Command.ACK_RESPONSE, + Command.DATA, + Command.ERROR_RESPONSE -> { + logger(LogLevel.VERBOSE) { "Verifying incoming packet with pump-client cipher" } + check(pumpStateStore.hasPumpState(pumpAddress)) { + "Cannot verify incoming ${packet.command} packet without a pump-client cipher" + } + packet.verifyAuthentication(cachedInvariantPumpData.pumpClientCipher) + } + + else -> true + } + if (!packetIsValid) + throw PacketVerificationException(packet) + + // Packets with the reliability flag set must be immediately + // responded to with an ACK_RESPONSE packet whose sequence bit + // must match that of the received packet. + if (packet.reliabilityBit) { + logger(LogLevel.VERBOSE) { + "Got a transport layer ${packet.command.name} packet with its reliability bit set; " + + "responding with ACK_RESPONSE packet; sequence bit: ${packet.sequenceBit}" + } + val ackResponsePacketInfo = createAckResponsePacketInfo(packet.sequenceBit) + + try { + sendInternal(ackResponsePacketInfo) + } catch (t: Throwable) { + logger(LogLevel.ERROR) { "Error while sending ACK_RESPONSE transport layer packet: $t" } + throw t + } + } + + // Check that this is a packet that we expect to be one that + // comes from the Combo. Some packets are only ever _sent_ to + // the Combo, so if we _receive_ them, something is wrong, + // and we must skip those packets. + // Also, the Combo periodically sends ACK_RESPONSE packets + // to us. These packets must be skipped, but they are not + // an error. Note that these ACK_RESPONSE are not the same + // as the ACK_RESPONSE packets above - those are sent _by_ + // us _to_ the Combo as a response to an incoming reliable + // packet, while here, we are talking about an ACK_RESPONSE + // packet coming _from_ the Combo. + val skipPacket = when (packet.command) { + Command.ACK_RESPONSE -> { + logger(LogLevel.VERBOSE) { "Got ACK_RESPONSE packet; skipping" } + true + } + Command.ERROR_RESPONSE, + Command.DATA, + Command.PAIRING_CONNECTION_REQUEST_ACCEPTED, + Command.KEY_RESPONSE, + Command.ID_RESPONSE, + Command.REGULAR_CONNECTION_REQUEST_ACCEPTED -> false + else -> { + logger(LogLevel.WARN) { "Cannot process ${packet.command.name} packet coming from the Combo; skipping packet" + } + true + } + } + + if (skipPacket) + return null + + // Perform some command specific processing. + when (packet.command) { + // When we get this command, we must reset the current + // sequence flag to make sure we start the regular + // connection with the correct flag. + // (Not doing this for pairing connections since this + // flag is never used during pairing.) + Command.REGULAR_CONNECTION_REQUEST_ACCEPTED -> currentSequenceFlag = false + Command.ERROR_RESPONSE -> processErrorResponsePacket(packet) + else -> Unit + } + + return packet + } + + // Produces a Packet that is to be sent to the Combo, + // and updates the state object's nonce (since every + // outgoing packet must have a unique nonce). It + // also flips the state's currentSequenceFlag if this + // is a reliable packet, and authenticates the packet + // with the appropriate cipher if necessary. + private fun produceOutgoingPacket(outgoingPacketInfo: OutgoingPacketInfo): Packet { + logger(LogLevel.VERBOSE) { "About to produce outgoing packet from info: $outgoingPacketInfo" } + + val nonce = when (outgoingPacketInfo.command) { + // These commands don't use a nonce, so we have + // to stick with the null nonce. + Command.REQUEST_PAIRING_CONNECTION, + Command.REQUEST_KEYS, + Command.GET_AVAILABLE_KEYS -> Nonce.nullNonce() + + // This is the first command that uses a non-null + // nonce. All packets after this one increment + // the nonce. See combo-comm-spec.adoc for details. + // That first nonce always has value 1. We return + // a hard-coded nonce here, since at this point, + // we cannot call getCurrentTxNonce() yet - the + // pump state is not yet set up. It will be once + // the ID_RESPONSE packet (which is the response + // to REQUEST_ID) arrives. + Command.REQUEST_ID -> Nonce(byteArrayListOfInts( + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + )) + + // These are the commands that are used in regular + // (= non-pairing) connections. They all increment + // the nonce. + Command.REQUEST_REGULAR_CONNECTION, + Command.ACK_RESPONSE, + Command.DATA -> pumpStateStore.incrementTxNonce(pumpAddress) + + else -> throw Error("This is not a valid outgoing packet") + } + + val address = when (outgoingPacketInfo.command) { + // Initial pairing commands use a hardcoded address. + Command.REQUEST_PAIRING_CONNECTION, + Command.REQUEST_KEYS, + Command.GET_AVAILABLE_KEYS -> 0xF0.toByte() + + Command.REQUEST_ID, + Command.REQUEST_REGULAR_CONNECTION, + Command.ACK_RESPONSE, + Command.DATA -> cachedInvariantPumpData.keyResponseAddress + + else -> throw Error("This is not a valid outgoing packet") + } + + val isCRCPacket = when (outgoingPacketInfo.command) { + Command.REQUEST_PAIRING_CONNECTION, + Command.REQUEST_KEYS, + Command.GET_AVAILABLE_KEYS -> true + + else -> false + } + + val reliabilityBit = outgoingPacketInfo.reliable + + // For reliable packets, use the current currentSequenceFlag + // as the sequence bit, then flip the currentSequenceFlag. + // For unreliable packets, don't touch the currentSequenceFlag, + // and clear the sequence bit. + // This behavior is overridden if sequenceBitOverride is + // non-null. In that case, the value of sequenceBitOverride + // is used for the sequence bit, and currentSequenceFlag + // is not touched. sequenceBitOverride is used for when + // ACK_RESPONSE packets have to be sent to the Combo. + val sequenceBit = + when { + outgoingPacketInfo.sequenceBitOverride != null -> outgoingPacketInfo.sequenceBitOverride + reliabilityBit -> { + val currentSequenceFlag = this.currentSequenceFlag + this.currentSequenceFlag = !this.currentSequenceFlag + currentSequenceFlag + } + else -> false + } + + val packet = Packet( + command = outgoingPacketInfo.command, + sequenceBit = sequenceBit, + reliabilityBit = reliabilityBit, + address = address, + nonce = nonce, + payload = outgoingPacketInfo.payload + ) + + if (isCRCPacket) { + packet.computeCRC16Payload() + logger(LogLevel.DEBUG) { + val crc16 = (packet.payload[1].toPosInt() shl 8) or packet.payload[0].toPosInt() + "Computed CRC16 payload ${crc16.toHexString(4)}" + } + } + + // Outgoing packets either use no cipher (limited to some + // of the initial pairing commands) or the client-pump cipher. + // The pump-client cipher is used for verifying incoming packets, + val cipher = when (outgoingPacketInfo.command) { + Command.REQUEST_PAIRING_CONNECTION, + Command.REQUEST_KEYS, + Command.GET_AVAILABLE_KEYS -> null + + Command.REQUEST_ID, + Command.REQUEST_REGULAR_CONNECTION, + Command.ACK_RESPONSE, + Command.DATA -> cachedInvariantPumpData.clientPumpCipher + + else -> throw Error("This is not a valid outgoing packet") + } + + // Authenticate the packet if necessary. + if (cipher != null) { + logger(LogLevel.VERBOSE) { "Authenticating outgoing packet" } + packet.authenticate(cipher) + } + + return packet + } + + // Reads the error ID out of the packet and throws an exception. + // This is appropriate, since an error message coming from the + // Combo is non-recoverable. + private fun processErrorResponsePacket(packet: Packet) { + if (packet.command != Command.ERROR_RESPONSE) + throw IncorrectPacketException(packet, Command.ERROR_RESPONSE) + if (packet.payload.size != 1) + throw InvalidPayloadException(packet, "Expected 1 byte, got ${packet.payload.size}") + + val errorID = packet.payload[0].toInt() + + throw ErrorResponseException(packet, errorID) + } + + private fun reopenPacketReceiverChannel() { + // Once a channel is closed, we can't use it anymore, + // so we must recreate it to effectively "reset" it. + packetReceiverChannel.close() + packetReceiverChannel = Channel( + capacity = Channel.UNLIMITED, + onBufferOverflow = BufferOverflow.SUSPEND + ) + } + } +} + +/** + * Produces a transport layer packet out of given data. + * + * This is just a convenience extension function that internally + * creates a TransportLayer.Packet instance and passes the data + * to its constructor. + * + * See the TransportLayer.Packet constructor for details. + */ +fun List.toTransportLayerPacket(): TransportLayer.Packet { + return TransportLayer.Packet(this) +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Twofish.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Twofish.kt new file mode 100644 index 0000000000..a3366acfe8 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Twofish.kt @@ -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:

+ //

+    //   g(x) = x**4 + (a + 1/a) x**3 + a x**2 + (a + 1/a) x + 1
+    // 
+ // 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 + ) + } +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Utility.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Utility.kt new file mode 100644 index 0000000000..b0ed0dae3e --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/base/Utility.kt @@ -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.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.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() diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/BasalProfile.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/BasalProfile.kt new file mode 100644 index 0000000000..bd4a45d5ee --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/BasalProfile.kt @@ -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) { + 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 = _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() +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStream.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStream.kt new file mode 100644 index 0000000000..3289f345cb --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStream.kt @@ -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(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 = _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(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/Pump.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/Pump.kt new file mode 100644 index 0000000000..a4e403cd42 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/Pump.kt @@ -0,0 +1,3233 @@ +package info.nightscout.comboctl.main + +import info.nightscout.comboctl.base.ApplicationLayer +import info.nightscout.comboctl.base.ApplicationLayer.CMDHistoryEventDetail +import info.nightscout.comboctl.base.BasicProgressStage +import info.nightscout.comboctl.base.BluetoothAddress +import info.nightscout.comboctl.base.BluetoothDevice +import info.nightscout.comboctl.base.BluetoothException +import info.nightscout.comboctl.base.ComboException +import info.nightscout.comboctl.base.ComboIOException +import info.nightscout.comboctl.base.CurrentTbrState +import info.nightscout.comboctl.base.DisplayFrame +import info.nightscout.comboctl.base.LogLevel +import info.nightscout.comboctl.base.Logger +import info.nightscout.comboctl.base.Nonce +import info.nightscout.comboctl.base.ProgressReport +import info.nightscout.comboctl.base.ProgressReporter +import info.nightscout.comboctl.base.ProgressStage +import info.nightscout.comboctl.base.PumpIO +import info.nightscout.comboctl.base.PumpIO.ConnectionRequestIsNotBeingAcceptedException +import info.nightscout.comboctl.base.PumpStateStore +import info.nightscout.comboctl.base.Tbr +import info.nightscout.comboctl.base.TransportLayer +import info.nightscout.comboctl.base.toStringWithDecimal +import info.nightscout.comboctl.base.withFixedYearFrom +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.ReservoirState +import kotlin.math.absoluteValue +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.asTimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.offsetAt +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime + +private val logger = Logger.get("Pump") + +private const val NUM_IDEMPOTENT_COMMAND_DISPATCH_ATTEMPTS = 10 +private const val DEFAULT_MAX_NUM_REGULAR_CONNECT_ATTEMPTS = 10 +private const val DELAY_IN_MS_BETWEEN_COMMAND_DISPATCH_ATTEMPTS = 2000L +private const val PUMP_DATETIME_UPDATE_LONG_RT_BUTTON_PRESS_THRESHOLD = 5 + +object RTCommandProgressStage { + /** + * Basal profile setting stage. + * + * @property numSetFactors How many basal rate factors have been set by now. + * When the basal profile has been fully set, this value equals the value of + * totalNumFactors. Valid range is 0 to ([NUM_COMBO_BASAL_PROFILE_FACTORS] - 1). + */ + data class SettingBasalProfile(val numSetFactors: Int) : ProgressStage("settingBasalProfile") + + /** + * Basal profile getting stage. + * + * @property numSetFactors How many basal rate factors have been retrieved by now. + * When the basal profile has been fully retrieved, this value equals the value + * of totalNumFactors. Valid range is 0 to ([NUM_COMBO_BASAL_PROFILE_FACTORS] - 1). + */ + data class GettingBasalProfile(val numSetFactors: Int) : ProgressStage("gettingBasalProfile") + + /** + * TBR percentage setting stage. + * + * @property settingProgress How far along the TBR percentage setting is, in the 0-100 range. + * 0 = procedure started. 100 = TBR percentage setting finished. + */ + data class SettingTBRPercentage(val settingProgress: Int) : ProgressStage("settingTBRPercentage") + + /** + * TBR duration setting stage. + * + * @property settingProgress How far along the TBR duration setting is, in the 0-100 range. + * 0 = procedure started. 100 = TBR duration setting finished. + */ + data class SettingTBRDuration(val settingProgress: Int) : ProgressStage("settingTBRDuration") + + /** + * Bolus delivery stage. + * + * The amounts are given in 0.1 IU units. For example, "57" means 5.7 IU. + * + * @property deliveredAmount How many units have been delivered so far. + * This is always <= totalAmount. + * @property totalAmount Total amount of bolus units. + */ + data class DeliveringBolus(val deliveredAmount: Int, val totalAmount: Int) : ProgressStage("deliveringBolus") + + /** + * TDD fetching history stage. + * + * @property historyEntryIndex Index of the history entry that was just fetched. + * Valid range is 1 to [totalNumEntries]. + * @property totalNumEntries Total number of entries in the history. + */ + data class FetchingTDDHistory(val historyEntryIndex: Int, val totalNumEntries: Int) : ProgressStage("fetchingTDDHistory") + + /** + * SetDateTime stage when the current hour is set. + */ + object SettingDateTimeHour : ProgressStage("settingDateTimeHour") + + /** + * SetDateTime stage when the current minute is set. + */ + object SettingDateTimeMinute : ProgressStage("settingDateTimeMinute") + + /** + * SetDateTime stage when the current year is set. + */ + object SettingDateTimeYear : ProgressStage("settingDateTimeYear") + + /** + * SetDateTime stage when the current month is set. + */ + object SettingDateTimeMonth : ProgressStage("settingDateTimeMonth") + + /** + * SetDateTime stage when the current day is set. + */ + object SettingDateTimeDay : ProgressStage("settingDateTimeDay") +} + +/** + * Main pump control class. + * + * This is the class that callers will mainly use for interacting with a pump. + * It takes care of IO with the pump and implements higher level commands like + * setting / getting the basal profile, delivering a bolus, getting / setting + * TBRs and the current datetime etc. + * + * To begin operating the pump, call [connect] to set up a Bluetooth connection. + * The connection can be terminated with [disconnect]. + * + * This class applies a series of checks for safety and robustness reasons. + * These are divided into checks performed by [connect] and checks performed + * before, during, and after command execution. See [connect] for a documentation + * about the on-connect checks. As for the command execution ones, these are: + * + * 1. Before each command, the Combo's warning & error flags are queried. + * If these are set, the Combo is switched to the remote terminal mode + * to "see" what warning/error is on the RT screen. That screen is parsed + * and processed. If it can't be handled locally, an [AlertScreenException] + * is thrown. + * 2. During command execution, if the execution fails due to connection issues, + * and the command is idempotent, this class attempts to reconnect to the pump, + * followed by another command execution attempt. This is repeated a number of + * times until execution succeeds or all possible attempts have been exhausted. + * If no attempt succeeded, [CommandExecutionAttemptsFailedException] is thrown. + * However, if the command is _not_ idempotent ([deliverBolus] is a notable + * example), then no repeat attempts are made. A command is idempotent if + * a command can be repeated safely. This is the case when repeated execution + * doesn't actually change anything unless the previous attempt failed. + * For example, if the same TBR is (re)started twice in quick succession, + * the second attempt effectively changes nothing. Repeating the same bolus + * however is _not_ idempotent since these boluses stack up, so failed bolus + * deliveries *must not* be repeated. + * 3. After command execution, the same check from step #1 is performed. + * + * All datetime timestamps are given as [Instant] values instead of localtime. + * This is done to ensure that timezone and/or daylight savings changes do + * not negatively affect operation of the pump. The pump's current datetime + * is automatically adjusted if it deviates from the current system datetime, + * and the system's current UTC offset is also stored (in the [PumpStateStore]). + * + * This class also informs callers about various events of type [Event]. + * Events can be for example "battery low", "TBR started", "bolus delivered" etc. + * When the Combo is suspended, a 0% 15-minute TBR event is emitted, since the + * suspended state effectively acts like such a 0% TBR. Events are emitted via + * the [onEvent] callback. Errors are communicated as exceptions, not as events. + * + * The class has a state (via [stateFlow]) and a status ([statusFlow]). The + * state informs about what the pump is currently doing or what it can + * currently do, while the status informs about various quantities in the + * pump, like how many IUs the pump's reservoir currently has. The status + * is updated by calling [updateStatus]. Note however that some functions + * like [connect] also automatically update the status. + * + * [initialBasalProfile] allows for setting a known basal profile as the + * current one. This does _not_ program that profile into the pump; instead, + * this sets the initial value of [currentBasalProfile]. If that property + * is null, [connect] will read the profile from the pump, so if the user + * is certain that the pump already contains a certain profile, setting + * this argument to that profile avoids an unnecessary basal profile read + * operation when connecting. + * + * IMPORTANT: The commands in this class are not designed to be executed + * concurrently (the Combo does not support this), so make sure these + * commands (for example, [setBasalProfile] and [deliverBolus]) are + * never called concurrently by multiple threads and/or coroutines. + * If necessary, use synchronization primitives. + * + * @param bluetoothDevice [BluetoothDevice] object to use for + * Bluetooth I/O. Must be in a disconnected state when + * assigned to this instance. + * @param pumpStateStore Pump state store to use. + * @param initialBasalProfile Basal profile to use as the initial value + * of [currentBasalProfile]. + * @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. + */ +class Pump( + private val bluetoothDevice: BluetoothDevice, + private val pumpStateStore: PumpStateStore, + initialBasalProfile: BasalProfile? = null, + private val onEvent: (event: Event) -> Unit = { } +) { + private val pumpIO = PumpIO(pumpStateStore, bluetoothDevice, this::processDisplayFrame, this::packetReceiverExceptionThrown) + // Updated by updateStatusImpl(). true if the Combo + // is currently in the stop mode. If true, commands + // are not executed, and an exception is thrown instead. + // See the checks in executeCommand() for details. + private var pumpSuspended = false + + // This is used in connectInternal() to prevent executeCommand() + // from attempting to reconnect. That's needed when connectInternal() + // performs post-connect pump check commands. + private var reconnectAttemptsEnabled = false + + // States for navigating through remote terminal (RT) screens. Needed by + // all commands that simulate user interactions in the RT mode, like + // setBasalProfile(). Not used by command-mode commands like [deliverBolus]. + private val parsedDisplayFrameStream = ParsedDisplayFrameStream() + private val rtNavigationContext = RTNavigationContextProduction(pumpIO, parsedDisplayFrameStream) + + // Used for keeping track of wether an RT alert screen was already dismissed + // (necessary since the screen may change its contents but still be the same screen). + private var rtScreenAlreadyDismissed = false + // Used in handleAlertScreenContent() to check if the current alert + // screen contains the same alert as the previous one. + private var lastObservedAlertScreenContent: AlertScreenContent? = null + + private var currentPumpUtcOffset: UtcOffset? = null + + // Command progress reporters. + + private val setBasalProfileReporter = createBasalProgressReporter() + private val getBasalProfileReporter = createBasalProgressReporter() + + private val setTbrProgressReporter = ProgressReporter( + listOf( + RTCommandProgressStage.SettingTBRPercentage::class, + RTCommandProgressStage.SettingTBRDuration::class + ), + Unit + ) { _: Int, _: Int, stage: ProgressStage, _: Unit -> + // TBR progress is divided in two stages, each of which have + // their own individual progress. Combine them by letting the + // SettingTBRPercentage stage cover the 0.0 - 0.5 progress + // range, and SettingTBRDuration cover the remaining 0.5 -1.0 + // progress range. + when (stage) { + BasicProgressStage.Finished, + is BasicProgressStage.Aborted -> 1.0 + is RTCommandProgressStage.SettingTBRPercentage -> + 0.0 + stage.settingProgress.toDouble() / 100.0 * 0.5 + is RTCommandProgressStage.SettingTBRDuration -> + 0.5 + stage.settingProgress.toDouble() / 100.0 * 0.5 + else -> 0.0 + } + } + + private val bolusDeliveryProgressReporter = ProgressReporter( + listOf( + RTCommandProgressStage.DeliveringBolus::class + ), + Unit + ) { _: Int, _: Int, stage: ProgressStage, _: Unit -> + // Bolus delivery progress is determined by the single + // stage in the reporter, which is DeliveringBolus. + // That stage contains how many IU have been delivered + // so far, which is suitable for a progress indicator, + // so we use that for the overall progress. + when (stage) { + BasicProgressStage.Finished, + is BasicProgressStage.Aborted -> 1.0 + is RTCommandProgressStage.DeliveringBolus -> + stage.deliveredAmount.toDouble() / stage.totalAmount.toDouble() + else -> 0.0 + } + } + + private val connectProgressReporter = ProgressReporter( + listOf( + BasicProgressStage.EstablishingBtConnection::class, + BasicProgressStage.PerformingConnectionHandshake::class + ), + Unit + ) + + private val setDateTimeProgressReporter = ProgressReporter( + listOf( + RTCommandProgressStage.SettingDateTimeHour::class, + RTCommandProgressStage.SettingDateTimeMinute::class, + RTCommandProgressStage.SettingDateTimeYear::class, + RTCommandProgressStage.SettingDateTimeMonth::class, + RTCommandProgressStage.SettingDateTimeDay::class + ), + Unit + ) + + private val tddHistoryProgressReporter = ProgressReporter( + listOf( + RTCommandProgressStage.FetchingTDDHistory::class + ), + Unit + ) { _: Int, _: Int, stage: ProgressStage, _: Unit -> + when (stage) { + // TDD history fetching progress is determined by the single + // stage in the reporter, which is FetchingTDDHistory. + // That stage contains the index of the TDD that was just + // read, which is suitable for a progress indicator, + // so we use that for the overall progress. + BasicProgressStage.Finished, + is BasicProgressStage.Aborted -> 1.0 + is RTCommandProgressStage.FetchingTDDHistory -> + stage.historyEntryIndex.toDouble() / stage.totalNumEntries.toDouble() + else -> 0.0 + } + } + + /** + * Empty base class for command descriptions used during the [State.ExecutingCommand] state. + * + * Callers can check for a specific subclass to determine the command that is + * being executed. Example: + * + * ``` + * when (executingCommandState.description) { + * is GettingBasalProfileCommandDesc -> println("Getting basal profile") + * is FetchingTDDHistoryCommandDesc -> println("Fetching TDD history") + * // etc. + * } + * ``` + */ + open class CommandDescription + + class GettingBasalProfileCommandDesc : CommandDescription() + class SettingBasalProfileCommandDesc : CommandDescription() + class UpdatingPumpDateTimeCommandDesc(val newPumpLocalDateTime: LocalDateTime) : CommandDescription() + class UpdatingPumpStatusCommandDesc : CommandDescription() + class FetchingTDDHistoryCommandDesc : CommandDescription() + class SettingTbrCommandDesc( + val percentage: Int, + val durationInMinutes: Int, + val type: Tbr.Type, + val force100Percent: Boolean + ) : CommandDescription() + class DeliveringBolusCommandDesc( + val bolusAmount: Int, + val bolusReason: StandardBolusReason + ) : CommandDescription() + + /** + * Exception thrown when an idempotent command failed every time. + * + * Idempotent commands are retried multiple times if they fail. If all attempts + * fail, the dispatcher gives up, and throws this exception instead. + */ + class CommandExecutionAttemptsFailedException : + ComboException("All attempts to execute the command failed") + + /** + * Exception thrown when setting the pump datetime fails. + */ + class SettingPumpDatetimeFailedException : + ComboException("Could not set pump datetime") + + class UnaccountedBolusDetectedException : + ComboException("Unaccounted bolus(es) detected") + + /** + * Exception thrown when something goes wrong with a bolus delivery. + * + * @param totalAmount Total bolus amount that was supposed to be delivered. In 0.1 IU units. + * @param message The detail message. + */ + open class BolusDeliveryException(val totalAmount: Int, message: String) : ComboException(message) + + /** + * Exception thrown when the Combo did not deliver the bolus at all. + * + * @param totalAmount Total bolus amount that was supposed to be delivered. In 0.1 IU units. + */ + class BolusNotDeliveredException(totalAmount: Int) : + BolusDeliveryException(totalAmount, "Could not deliver bolus amount of ${totalAmount.toStringWithDecimal(1)} IU") + + /** + * Exception thrown when the bolus delivery was cancelled. + * + * @param deliveredAmount Bolus amount that was delivered before the bolus was cancelled. In 0.1 IU units. + * @param totalAmount Total bolus amount that was supposed to be delivered. In 0.1 IU units. + */ + class BolusCancelledByUserException(val deliveredAmount: Int, totalAmount: Int) : + BolusDeliveryException( + totalAmount, + "Bolus cancelled (delivered amount: ${deliveredAmount.toStringWithDecimal(1)} IU " + + "total programmed amount: ${totalAmount.toStringWithDecimal(1)} IU" + ) + + /** + * Exception thrown when the bolus delivery was aborted due to an error. + * + * @param deliveredAmount Bolus amount that was delivered before the bolus was aborted. In 0.1 IU units. + * @param totalAmount Total bolus amount that was supposed to be delivered. + */ + class BolusAbortedDueToErrorException(deliveredAmount: Int, totalAmount: Int) : + BolusDeliveryException( + totalAmount, + "Bolus aborted due to an error (delivered amount: ${deliveredAmount.toStringWithDecimal(1)} IU " + + "total programmed amount: ${totalAmount.toStringWithDecimal(1)} IU" + ) + + /** + * Exception thrown when there isn't enough insulin in the reservoir for the bolus to be delivered. + * + * IMPORTANT: Bolus amount is given in 0.1 IU units, while the available units in the + * reservoir are given in whole 1 IU units. + * + * @param bolusAmount Bolus amount that was attempted to be delivered. In 0.1 IU units. + * @param availableUnitsInReservoir Number of units in the reservoir. In 1 IU units. + */ + class InsufficientInsulinAvailableException(bolusAmount: Int, val availableUnitsInReservoir: Int) : + BolusDeliveryException( + bolusAmount, + "Insufficient insulin in reservoir for bolus: bolus amount: ${bolusAmount.toStringWithDecimal(1)} IU " + + "available units in reservoir: $availableUnitsInReservoir" + ) + + /** + * Exception thrown when the TBR that was passed to setTbr() does not match the actually active TBR. + * + * If no TBR is active, [actualTbrDuration] is 0. If no TBR was expected to be active, + * [expectedTbrDuration] is 0. + */ + class UnexpectedTbrStateException( + val expectedTbrPercentage: Int, + val expectedTbrDuration: Int, + val actualTbrPercentage: Int, + val actualTbrDuration: Int + ) : ComboException( + "Expected TBR: $expectedTbrPercentage% $expectedTbrDuration minutes ; " + + "actual TBR: $actualTbrPercentage% $actualTbrDuration minutes" + ) + + /** + * Exception thrown when the main screen shows information about an active extended / multiwave bolus. + * + * These bolus type are currently not supported and cannot be handled properly. + * + * @property bolusInfo Information about the detected extended / multiwave bolus. + */ + class ExtendedOrMultiwaveBolusActiveException(val bolusInfo: MainScreenContent.ExtendedOrMultiwaveBolus) : + ComboException("Extended or multiwave bolus is active; bolus info: $bolusInfo") + + /** + * Reason for a standard bolus delivery. + * + * A standard bolus may be delivered for various reasons. + */ + enum class StandardBolusReason { + /** + * This is a normal bolus. + */ + NORMAL, + + /** + * This is a superbolus. + */ + SUPERBOLUS, + + /** + * This is a bolus that is used for priming an infusion set. + */ + PRIMING_INFUSION_SET + } + + /** + * Events that can occur during operation. + * + * These are forwarded through the [onEvent] property. + * + * IMPORTANT: Bolus amounts are given in 0.1 IU units, + * so for example, "57" means 5.7 IU. + */ + sealed class Event { + object BatteryLow : Event() + object ReservoirLow : Event() + data class QuickBolusRequested( + val bolusId: Long, + val timestamp: Instant, + val bolusAmount: Int + ) : Event() + data class QuickBolusInfused( + val bolusId: Long, + val timestamp: Instant, + val bolusAmount: Int + ) : Event() + data class StandardBolusRequested( + val bolusId: Long, + val timestamp: Instant, + val manual: Boolean, + val bolusAmount: Int, + val standardBolusReason: StandardBolusReason + ) : Event() + data class StandardBolusInfused( + val bolusId: Long, + val timestamp: Instant, + val manual: Boolean, + val bolusAmount: Int, + val standardBolusReason: StandardBolusReason + ) : Event() + data class ExtendedBolusStarted( + val bolusId: Long, + val timestamp: Instant, + val totalBolusAmount: Int, + val totalDurationMinutes: Int + ) : Event() + data class ExtendedBolusEnded( + val bolusId: Long, + val timestamp: Instant, + val totalBolusAmount: Int, + val totalDurationMinutes: Int + ) : Event() + data class MultiwaveBolusStarted( + val bolusId: Long, + val timestamp: Instant, + val totalBolusAmount: Int, + val immediateBolusAmount: Int, + val totalDurationMinutes: Int + ) : Event() + data class MultiwaveBolusEnded( + val bolusId: Long, + val timestamp: Instant, + val totalBolusAmount: Int, + val immediateBolusAmount: Int, + val totalDurationMinutes: Int + ) : Event() + data class TbrStarted(val tbr: Tbr) : Event() + data class TbrEnded(val tbr: Tbr, val timestampWhenTbrEnded: Instant) : Event() + data class UnknownTbrDetected( + val tbrPercentage: Int, + val remainingTbrDurationInMinutes: Int + ) : Event() + } + + /** + * The pump's Bluetooth address. + */ + val address: BluetoothAddress = bluetoothDevice.address + + /** + * Read-only [SharedFlow] property that delivers newly assembled and parsed display frames. + * + * See [ParsedDisplayFrame] for details about these frames. + */ + val parsedDisplayFrameFlow: SharedFlow = parsedDisplayFrameStream.flow + + /** + * Read-only [StateFlow] property that announces when the current [PumpIO.Mode] changed. + * + * This flow's value is null until the connection is fully established (at which point + * the mode is set to [PumpIO.Mode.REMOTE_TERMINAL] or [PumpIO.Mode.COMMAND]), and + * set back to null again after disconnecting. + */ + val currentModeFlow: StateFlow = pumpIO.currentModeFlow + + /** + * Possible states the pump can be in. + */ + sealed class State { + /** + * There is no connection to the pump. This is the initial state. + */ + object Disconnected : State() + + /** + * Connection to the pump is being established. This state is set + * while [connect] is running. If connecting fails, the state + * is set to [Error], otherwise it is set to [CheckingPump], + * [Suspended], or [ReadyForCommands]. + */ + object Connecting : State() + + /** + * After connection was established, [connect] performs checks + * (if [performOnConnectChecks] is set to true). The pump state + * is set to this one while these checks are running. + * If [performOnConnectChecks] is set to false, this state + * is never set. Instead, after [Connecting], the state transitions + * directly to [ReadyForCommands], [Suspended], or [Error]. + */ + object CheckingPump : State() + + /** + * After successfully connecting and performing the checks, this + * becomes the current state. Commands can be run in this state. + * If the Combo is stopped (also known as "suspended"), the + * state is set to [Suspended] instead (see below). + */ + object ReadyForCommands : State() + + /** + * A command is currently being executed. This state remains set + * until the command execution finishes. If it finishes successfully, + * it is set back to [ReadyForCommands]. If an error occurs, + * it is set to [Error]. The [description] provides information for + * UIs to show the user what command is being executed. + */ + data class ExecutingCommand(val description: CommandDescription) : State() + + /** + * The Combo is currently stopped (= suspended). No commands can + * be executed. This is not an error, but the user has to resume + * pump operation manually. + */ + object Suspended : State() + + /** + * An error occurred during connection setup or command execution. + * Said error was non-recoverable. The only valid operation that + * can be performed in this state is to call [disconnect]. + * Commands cannot be executed in this state. + * + * @property throwable Optional reference to a Throwable that triggered this error state. + * @property message Optional human-readable message describing the error. + * This is meant for logging purposes. + */ + data class Error(val throwable: Throwable? = null, val message: String? = null) : State() { + override fun toString(): String { + return if (throwable != null) + "Error (\"$message\"); throwable: $throwable" + else + "Error (\"$message\")" + } + } + } + + private val _stateFlow = MutableStateFlow(State.Disconnected) + + /** + * [StateFlow] that notifies about the pump's current state. + */ + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + /** + * [StateFlow] for reporting progress during the [connect] call. + * + * See the [ProgressReporter] documentation for details. + */ + val connectProgressFlow: StateFlow = connectProgressReporter.progressFlow + + /** + * [ProgressReporter] flow for reporting progress while the pump datetime is set. + * + * See the [ProgressReporter] documentation for details. + * + * This flow consists of these stages (aside from Finished/Aborted/Idle): + * + * - [RTCommandProgressStage.SettingDateTimeHour] + * - [RTCommandProgressStage.SettingDateTimeMinute] + * - [RTCommandProgressStage.SettingDateTimeYear] + * - [RTCommandProgressStage.SettingDateTimeMonth] + * - [RTCommandProgressStage.SettingDateTimeDay] + */ + val setDateTimeProgressFlow = setDateTimeProgressReporter.progressFlow + + /** + * Pump status. + * + * This contains status information like the number of available + * units in the reservoir, the percentage of a currently ongoing + * TBR, the battery state etc. + * + * There is no field that specifies whether the Combo is running + * or stopped. That's because that information is already covered + * by [State.Suspended]. + * + * A [currentBasalRateFactor] is special in that it indicates + * that [updateStatus] could not get the current factor. This + * happens when the pump is stopped (the main screen does not + * show any factor then). It also happens when a 0% TBR is + * active (the factor shown on screen is then always 0 regardless + * or what the actual underlying factor is). + */ + data class Status( + val availableUnitsInReservoir: Int, + val activeBasalProfileNumber: Int, + val currentBasalRateFactor: Int, + val tbrOngoing: Boolean, + val remainingTbrDurationInMinutes: Int, + val tbrPercentage: Int, + val reservoirState: ReservoirState, + val batteryState: BatteryState + ) + + private val _statusFlow = MutableStateFlow(null) + + /** + * [StateFlow] that notifies about the pump's current status. + * + * This is updated by the [updateStatus] function. Initially, + * it is set to null. It is set to null again after disconnecting. + */ + val statusFlow = _statusFlow.asStateFlow() + + /** + * The basal profile that is currently being used. + * + * This is initially set to the profile that is passed to [Pump]'s + * constructor. If [setBasalProfile] is called, and the pump's + * profile is updated, then so is this property. + */ + var currentBasalProfile: BasalProfile? = initialBasalProfile + private set + + /** + * Information about the last bolus. See [lastBolusFlow]. + * + * NOTE: This only reports quick and standard boluses, not multiwave and extended ones. + * + * @property bolusId ID associated with this bolus. + * @property bolusAmount Bolus amount, in 0.1 IU units. + * @property timestamp Timestamp of the bolus delivery. + */ + data class LastBolus(val bolusId: Long, val bolusAmount: Int, val timestamp: Instant) + + private var _lastBolusFlow = MutableStateFlow(null) + + /** + * Informs about the last bolus that was administered during this connection. + * + * Boluses that might have happened in an earlier connection are not looked + * at. This is purely about the _current_ connection. + */ + val lastBolusFlow = _lastBolusFlow.asStateFlow() + + private var _currentTbrFlow = MutableStateFlow(null) + + /** + * Informs about a currently active TBR. + * + * Along with [Event.TbrStarted], [Event.TbrEnded], and the TBR details in + * [Status], this is an additional way to get informed about TBR activity, + * and is mostly useful for UI updates. If no TBR is ongoing, the flow's + * value is set to null. + */ + val currentTbrFlow = _currentTbrFlow.asStateFlow() + + /** + * Unpairs the pump. + * + * Unpairing consists of deleting any associated pump state, + * followed by unpairing the Bluetooth device. + * + * This disconnects before unpairing to make sure there + * is no ongoing connection while attempting to unpair. + * + * If the pump isn't paired already, this function does nothing. + * + * NOTE: This removes pump data from ComboCtl's pump state store + * and unpairs the Combo at the Bluetooth level, but does _not_ + * remove this client from the Combo. The user still has to + * operate the Combo's local LCD UI to manually remove this + * client from the Combo in its Bluetooth settings. There is + * no way to do this remotely by the client. + */ + suspend fun unpair() { + if (!pumpStateStore.hasPumpState(address)) + return + + disconnect() + + pumpStateStore.deletePumpState(address) + + // Unpairing in a coroutine with an IO dispatcher + // in case unpairing blocks. + withContext(bluetoothDevice.ioDispatcher) { + bluetoothDevice.unpair() + } + + logger(LogLevel.INFO) { "Unpaired from pump with address ${bluetoothDevice.address}" } + } + + /** + * Establishes a connection to the Combo. + * + * This suspends the calling coroutine until the connection + * is up and running, a connection error occurs, or the + * calling coroutine is cancelled. + * + * This changes the current state multiple times. These + * updates are accessible through [stateFlow]. Initially, + * the state is set to [State.Connecting]. Once the underlying + * Bluetooth device is connected, this function transitions to + * the [State.CheckingPump] state and performs checks on the + * pump (described below). As part of these checks, if the Combo + * is found to be currently stopped (= suspended), the state is + * set to [State.Suspended], otherwise it is set to + * [State.ReadyForCommands]. At this point, this function + * finishes successfully. + * + * If any error occurs while this function runs, the state + * is set to [State.Error]. If the calling coroutine is cancelled, + * the state is instead set to [State.Disconnected] because + * cancellation rolls back any partial connection setup that + * might have been done by the time the cancellation occurs. + * + * At each connection setup, a series of checks are performed.: + * + * 1. [updateStatus] is called to get the current up-to-date status, + * which is needed by other checks. This also updates the [statusFlow]. + * 2. The command mode history delta is retrieved. This contains all + * delivered boluses since the last time the history delta was retrieved. + * If no boluses happened in between connections, this list will be empty. + * Otherwise, unaccounted boluses happened. These are announced via [onEvent]. + * 3. The current pump status is evaluated. If the pump is found to be + * suspended, the [stateFlow] switches to [State.Suspended], the checks + * end, and so does this function. Otherwise, it continues. + * 4. The TBR state is evaluated according to the information from + * [PumpStateStore] and what is displayed on the main Combo screen + * (this is retrieved by [updateStatus] in the remote terminal mode). + * If an unknown TBR is detected, then that unknown TBR is cancelled, + * and [Event.UnknownTbrDetected] is emitted via [onEvent]. + * 5. If [currentBasalProfile] is null, or if the current basal rate + * that is shown on the main Combo RT screen does not match the current + * basal rate from the profile at this hour, the basal profile is read + * from the Combo, and [currentBasalProfile] is updated. The basal + * profile retrieval can be tracked via [getBasalProfileFlow]. + * 6. The current pump's datetime is updated to match the current + * system datetime if there is a mismatch. This is done through the + * remote terminal mode. The progress can be tracked by watching the + * [setDateTimeProgressFlow]. + * 7. The current pump's UTC offset is updated to match the current + * system's UTC offset if there is a mismatch. The UTC offset is + * written to [pumpStateStore]. + * + * Since no two clients can deliver a bolus, set a TBR etc. on the same + * Combo simultaneously, these checks do not have to be performed before + * each command - it is sufficient to do them upon connection setup. + * + * If IO errors happen during a connection attempt, this function tries + * to establish the connection again. The maximum number of attempts is + * specified by the [maxNumAttempts] argument. It can be set to null to + * allow for an unlimited amount of attempts. This should only be used + * if the caller has some sort of custom timeout mechanism at which the + * [disconnect] function is called (which makes this function abort). + * + * This function also handles a special situation if the [Nonce] that is + * stored in [PumpStateStore] for this pump is incorrect. The Bluetooth + * socket can then be successfully connected, but right afterwards, when + * this function tries to send a [TransportLayer.Command.REQUEST_REGULAR_CONNECTION] + * packet, the Combo does not respond, instead terminating the connection + * and producing a [BluetoothException]. If this happens, this function + * increments the nonce and tries again. This is done multiple times + * until either the connection setup succeeds or the maximum number of + * attempts is reached. In the latter case, this function throws a + * [ConnectionRequestIsNotBeingAcceptedException]. The user should then + * be recommended to re-pair with the Combo, since establishing a connection + * isn't working. + * + * @throws IllegalStateException if the current state is not + * [State.Disconnected] (calling [connect] while a connection is present + * makes no sense). + * @throws ConnectionRequestIsNotBeingAcceptedException if connecting the + * actual Bluetooth socket succeeds, but the Combo does not accept the + * packet that requests a connection, and this failed several times + * in a row. + * @throws AlertScreenException if the pump reports errors or + * unhandled warnings during the connection setup and/or + * pump checks. + * @throws SettingPumpDatetimeFailedException if during the checks, + * the pump's datetime was found to be deviating too much from the + * actual current datetime, and adjusting the pump's datetime failed. + * @throws ExtendedOrMultiwaveBolusActiveException if an extended / multiwave + * bolus is active (these are shown on the main screen). + */ + suspend fun connect(maxNumAttempts: Int? = DEFAULT_MAX_NUM_REGULAR_CONNECT_ATTEMPTS) { + check(stateFlow.value == State.Disconnected) { "Attempted to connect to pump in the ${stateFlow.value} state" } + check((maxNumAttempts == null) || (maxNumAttempts > 0)) + + val actualMaxNumAttempts = maxNumAttempts ?: Int.MAX_VALUE + + for (connectionAttemptNr in 1..actualMaxNumAttempts) { + connectProgressReporter.reset(Unit) + + logger(LogLevel.DEBUG) { "Attempt no. $connectionAttemptNr to establish connection" } + + connectProgressReporter.setCurrentProgressStage(BasicProgressStage.EstablishingBtConnection( + currentAttemptNr = connectionAttemptNr, + totalNumAttempts = maxNumAttempts + )) + + try { + connectInternal() + break + } catch (e: CancellationException) { + connectProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled) + pumpIO.disconnect() + _statusFlow.value = null + parsedDisplayFrameStream.resetAll() + setState(State.Disconnected) + throw e + } catch (e: ComboException) { + pumpIO.disconnect() + _statusFlow.value = null + parsedDisplayFrameStream.resetAll() + when (e) { + // If these exceptions occur, do _not_ try another connection attempt. + // Instead, disconnect and forward these exceptions, as if all attempts + // failed. That's because these exceptions indicate hard errors that + // must be reported ASAP and disallow more connection attempts, at + // least attempts without notifying the user. + is ExtendedOrMultiwaveBolusActiveException, + is SettingPumpDatetimeFailedException, + is AlertScreenException -> { + setState(State.Error(throwable = e, "Connection error")) + throw e + } + else -> Unit + } + if (connectionAttemptNr < actualMaxNumAttempts) { + logger(LogLevel.DEBUG) { "Got exception while connecting; will try again; exception was: $e" } + delay(DELAY_IN_MS_BETWEEN_COMMAND_DISPATCH_ATTEMPTS) + continue + } else { + logger(LogLevel.ERROR) { + "Got exception $e while connecting, and max number of " + + "connection establishing attempts reached; not trying again" + } + connectProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e)) + setState(State.Error(throwable = e, "Connection error")) + throw e + } + } catch (t: Throwable) { + connectProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(t)) + setState(State.Error(throwable = t, "Connection error")) + throw t + } + } + } + + /** + * Terminates an ongoing connection previously established by [connect]. + * + * If no connection is ongoing, this does nothing. + * + * This function resets the pump state and undoes a [State.Error] state. + * In case of an error, the user has to call [disconnect] to reset back + * to the [State.Disconnected] state. Afterwards, the user can try again + * to establish a new connection. + * + * This sets [statusFlow] to null and [stateFlow] to [State.Disconnected]. + */ + suspend fun disconnect() { + if (stateFlow.value == State.Disconnected) { + logger(LogLevel.DEBUG) { "Ignoring disconnect() call since pump is already disconnected" } + return + } + + pumpIO.disconnect() + _statusFlow.value = null + parsedDisplayFrameStream.resetAll() + reconnectAttemptsEnabled = false + setState(State.Disconnected) + } + + /** + * [ProgressReporter] flow for keeping track of the progress of [setBasalProfile]. + */ + val setBasalProfileFlow = setBasalProfileReporter.progressFlow + + /** + * [ProgressReporter] flow for keeping track of the progress of when the pump's basal profile is read. + * + * This happens when a [connect] call determines that reading + * the profile from the pump is necessary at the time of that + * function call. + */ + val getBasalProfileFlow = getBasalProfileReporter.progressFlow + + /** + * Sets [basalProfile] as the new basal profile to use in the pump. + * + * This programs the pump to use this basal profile by simulating user + * interaction in the remote terminal mode. There is no command-mode + * command to directly pass the 24 profile factors to the pump, so + * it has to be set by doing the aforementioned simulation. This is + * relatively slow, so it is recommended to use [setBasalProfileFlow] + * to provide some form of progress indicator (like a progress bar) + * to the user. + * + * If [currentBasalProfile] is not null, this function compares + * [basalProfile] to that profile. If their factors equal, this + * function does nothing. That way, redundant calls are caught and + * ignored. If [currentBasalProfile] is null, or if its factors do + * not match those of [basalProfile], then it is set to [basalProfile]. + * + * If [carryOverLastFactor] is set to true (the default value), this function + * moves between basal profile factors by pressing the UP and DOWN buttons + * simultaneously instead of the MENU button. This copies over the last + * factor that was being programmed in to the next factor. If this is false, + * the MENU key is pressed. The pump then does not carry over anything to the + * next screen; instead, the currently programmed in factor shows up. + * Typically, carrying over the last factor is faster, which is why this is + * set to true by default. There might be corner cases where setting this to + * false results in faster execution, but at the moment, none are known. + * + * This also checks if setting the profile is actually necessary by comparing + * [basalProfile] with [currentBasalProfile]. If these match, this function + * does not set anything, and just returns false. Otherwise, it sets the + * new profile, sets [basalProfile] as the new [currentBasalProfile], + * and returns true. Note that a return value of false is _not_ an error. + * + * @param basalProfile New basal profile to program into the pump. + * @param carryOverLastFactor If set to true, previously programmed in factors + * are carried to the next factor while navigating through the profile. + * @return true if the profile was actually set, false otherwise. + * @throws AlertScreenException if an alert occurs during this call. + * @throws IllegalStateException if the current state is not + * [State.ReadyForCommands]. + */ + suspend fun setBasalProfile(basalProfile: BasalProfile, carryOverLastFactor: Boolean = true) = executeCommand( + pumpMode = PumpIO.Mode.REMOTE_TERMINAL, + isIdempotent = true, + description = SettingBasalProfileCommandDesc(), + allowExecutionWhileSuspended = true + ) { + if (basalProfile == currentBasalProfile) { + logger(LogLevel.DEBUG) { "Current basal profile equals the profile that is to be set; ignoring redundant call" } + return@executeCommand false + } + + setBasalProfileReporter.reset(Unit) + + setBasalProfileReporter.setCurrentProgressStage(RTCommandProgressStage.SettingBasalProfile(0)) + + // Refine the adjustment behavior. If the quantity on screen + // is only slightly deviating from what we want to configure, + // only use short RT button presses. + val longRTButtonPressPredicate = fun(targetQuantity: Int, quantityOnScreen: Int): Boolean { + val quantityDelta = (targetQuantity - quantityOnScreen).absoluteValue + // Distinguish between <1.0 and >= 1.0 IU quantities, + // since the granularity of adjustments differs below + // and above the 1.0 IU threshold (below, the quantity + // is incremented in 0.01 IU steps, above in 0.05 ones). + return if ((targetQuantity <= 1000) && (quantityOnScreen <= 1000)) { + (quantityDelta >= 150) + } else if ((targetQuantity >= 1000) && (quantityOnScreen >= 1000)) { + (quantityDelta >= 500) + } else { + val (quantityBelow1IU, quantityAbove1IU) = if (targetQuantity < quantityOnScreen) + Pair(targetQuantity, quantityOnScreen) + else + Pair(quantityOnScreen, targetQuantity) + + ((1000 - quantityBelow1IU) > 150) || ((quantityAbove1IU - 1000) > 500) + } + } + + try { + val firstBasalRateFactorScreen = + navigateToRTScreen(rtNavigationContext, ParsedScreen.BasalRateFactorSettingScreen::class, pumpSuspended) + + // Store the hours at which the current basal rate factor + // begins to ensure that during screen cycling we + // actually get to the next factor (which begins at + // different hours). + var previousBeginHour = (firstBasalRateFactorScreen as ParsedScreen.BasalRateFactorSettingScreen).beginTime.hour + + for (index in 0 until basalProfile.size) { + val basalFactor = basalProfile[index] + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = basalFactor, + longRTButtonPressPredicate = longRTButtonPressPredicate, + // The in/decrement steps go as follows (the range is for the basal factor on screen): + // 0.0 - 0.05 IU : 0.05 IU steps + // 0.05 - 1.0 IU : 0.01 IU steps + // 1.0 - 10.0 IU : 0.05 IU steps + // above 10.0 IU : 0.1 IU steps + incrementSteps = arrayOf(Pair(0, 50), Pair(50, 10), Pair(1000, 50), Pair(10000, 100)) + ) { + (it as ParsedScreen.BasalRateFactorSettingScreen).numUnits + } + + setBasalProfileReporter.setCurrentProgressStage(RTCommandProgressStage.SettingBasalProfile(index + 1)) + + // By pushing MENU or UP_DOWN we move to the next basal rate factor. + // If we are at the last factor, and are about to transition back to + // the first one again, we always press MENU to make sure the first + // factor isn't overwritten by the last factor that got carried over. + rtNavigationContext.shortPressButton( + if (carryOverLastFactor && (index != (basalProfile.size - 1))) + RTNavigationButton.UP_DOWN + else + RTNavigationButton.MENU + ) + + rtNavigationContext.resetDuplicate() + + // Wait until we actually get a different BasalRateFactorSettingScreen. + // The pump might send us the same screen multiple times, because it + // might be blinking, so it is important to wait until the button press + // above actually resulted in a change to the screen with the next factor. + while (true) { + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + + parsedScreen as ParsedScreen.BasalRateFactorSettingScreen + if (parsedScreen.beginTime.hour != previousBeginHour) { + previousBeginHour = parsedScreen.beginTime.hour + break + } + } + } + + // All factors are set. Press CHECK once to get back to the total + // basal rate screen, and then CHECK again to store the new profile + // and return to the main menu. + + rtNavigationContext.shortPressButton(RTNavigationButton.CHECK) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.BasalRateTotalScreen::class) + + rtNavigationContext.shortPressButton(RTNavigationButton.CHECK) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.MainScreen::class) + + setBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Finished) + + currentBasalProfile = basalProfile + + return@executeCommand true + } catch (e: CancellationException) { + setBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Cancelled) + throw e + } catch (t: Throwable) { + setBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Error(t)) + throw t + } + } + + /** + * [ProgressReporter] flow for keeping track of the progress of [setTbr]. + */ + val setTbrProgressFlow = setTbrProgressReporter.progressFlow + + /** + * Sets the Combo's current temporary basal rate (TBR) via the remote terminal (RT) mode. + * + * This function suspends until the TBR is fully set. The [tbrProgressFlow] + * can be used to get informed about the TBR setting progress. Since setting + * a TBR can take a while, it is recommended to make use of this to show + * some sort of progress indicator on a GUI. + * + * If [percentage] is 100, and [force100Percent] is true, any ongoing TBR will be + * cancelled. The Combo will produce a W6 warning screen when this happens. This + * screen is automatically dismissed by this function before it exits. If instead + * [percentage] is 100 but [force100Percent] is false, this function will actually + * start a 15-minute TBR of 90% or 110%, depending on the current TBR. (If the + * current TBR is less than 100%, a 15-minute 110% TBR is started, otherwise a + * 15-minute 90% TBR starts.) This is done to avoid the vibration that accompanies + * the aforementioned W6 warning. + * + * [percentage] must be in the range 0-500 (specifying the % of the TBR), + * and an integer multiple of 10. + * [durationInMinutes] must be at least 15 (since the Combo cannot do TBRs + * that are shorter than 15 minutes), and must an integer multiple of 15. + * Maximum allowed duration is 24 hours, so the maximum valid value is 1440. + * However, if [percentage] is 100, the value of [durationInMinutes] + * is ignored. + * + * This also automatically cancels any TBR that may be ongoing, replacing it with + * the newly set TBR. (This cancelling does not produce any W6 warnings, since + * they are instantly replaced by the new TBR.) + * + * As soon as a TBR is started by this function, [Event.TbrStarted] is emitted + * via the [onEvent] callback. Likewise, when a TBR finishes or is cancelled, + * [Event.TbrEnded] is emitted. + * + * @param percentage TBR percentage to set. + * @param durationInMinutes TBR duration in minutes to set. + * This argument is not used if [percentage] is 100. + * @param type Type of the TBR. Only [Tbr.Type.NORMAL], [Tbr.Type.SUPERBOLUS], + * and [Tbr.Type.EMULATED_COMBO_STOP] can be used here. + * This argument is not used if [percentage] is 100. + * @param force100Percent Whether to really set the TBR to 100% (= actually + * cancelling an ongoing TBR, which produces a W6 warning) or to fake a + * 100% TBR by setting 90% / 110% TBRs (see above). + * This argument is only used if [percentage] is 100. + * @throws IllegalArgumentException if the percentage is not in the 0-500 range, + * or if the percentage value is not an integer multiple of 10, or if + * the duration is <15 or not an integer multiple of 15 (see the note + * about duration being ignored with percentage 100 above though). + * @throws UnexpectedTbrStateException if the TBR that is actually active + * after this function finishes does not match the specified percentage + * and duration. + * @throws ExtendedOrMultiwaveBolusActiveException if an extended / multiwave + * bolus is active after setting the TBR. (This should not normally happen, + * since it is not possible for users to set such a bolus while also setting + * the TBR, but is included for completeness.) + * @throws IllegalStateException if the current state is not + * [State.ReadyForCommands], or if the pump is suspended after setting the TBR. + * @throws AlertScreenException if alerts occurs during this call, and they + * aren't a W6 warning (those are handled by this function). + */ + suspend fun setTbr( + percentage: Int, + durationInMinutes: Int, + type: Tbr.Type, + force100Percent: Boolean = false + ) = executeCommand( + pumpMode = PumpIO.Mode.REMOTE_TERMINAL, + isIdempotent = true, + description = SettingTbrCommandDesc(percentage, durationInMinutes, type, force100Percent) + ) { + require(type in listOf(Tbr.Type.NORMAL, Tbr.Type.SUPERBOLUS, Tbr.Type.EMULATED_COMBO_STOP)) { "Invalid TBR type" } + + // NOTE: Not using the Tbr class directly as a function argument since + // the timestamp property of that class is not useful here. The Tbr + // class is rather meant for TBR events. + + val currentStatus = statusFlow.value ?: throw IllegalStateException("Cannot start TBR without a known pump status") + var expectedTbrPercentage: Int + var expectedTbrDuration: Int + + // In the code below, we always create a Tbr object _before_ calling + // setCurrentTbr to make use of the checks in the Tbr constructor. + // If percentage and/or durationInMinutes are invalid, these checks + // will throw an IllegalArgumentException. We want to do this + // before actually setting the TBR. + + if (percentage == 100) { + if (currentStatus.tbrPercentage != 100) { + if (force100Percent) { + setCurrentTbr(100, 0) + reportOngoingTbrAsStopped() + expectedTbrPercentage = 100 + expectedTbrDuration = 0 + } else { + val newPercentage = if (currentStatus.tbrPercentage < 100) 110 else 90 + val tbr = Tbr( + timestamp = Clock.System.now(), + percentage = newPercentage, + durationInMinutes = 15, + Tbr.Type.EMULATED_100_PERCENT + ) + setCurrentTbr(percentage = newPercentage, durationInMinutes = 15) + reportStartedTbr(tbr) + expectedTbrPercentage = newPercentage + expectedTbrDuration = 15 + } + } else { + // Current status shows that there is no TBR ongoing. This is + // therefore a redunant call. Handle this by expecting a 100% + // basal rate to make sure the checks below don't throw anything. + expectedTbrPercentage = 100 + expectedTbrDuration = 0 + logger(LogLevel.INFO) { "TBR was already cancelled" } + } + } else { + val tbr = Tbr( + timestamp = Clock.System.now(), + percentage = percentage, + durationInMinutes = durationInMinutes, + type + ) + tbr.checkDurationForCombo() + setCurrentTbr(percentage = percentage, durationInMinutes = durationInMinutes) + reportStartedTbr(tbr) + expectedTbrPercentage = percentage + expectedTbrDuration = durationInMinutes + } + + // We just set the TBR. Now check the main screen contents to see if + // the TBR was actually set, and if so, whether it was set correctly. + // If not, throw an exception, since this is an error. + + val mainScreen = waitUntilScreenAppears(rtNavigationContext, ParsedScreen.MainScreen::class) + val mainScreenContent = when (mainScreen) { + is ParsedScreen.MainScreen -> mainScreen.content + else -> throw NoUsableRTScreenException() + } + logger(LogLevel.DEBUG) { + "Main screen content after setting TBR: $mainScreenContent; expected TBR " + + "percentage / duration: $expectedTbrPercentage / $expectedTbrDuration" + } + when (mainScreenContent) { + is MainScreenContent.Stopped -> + throw IllegalStateException("Combo is in the stopped state after setting TBR") + + is MainScreenContent.ExtendedOrMultiwaveBolus -> + throw ExtendedOrMultiwaveBolusActiveException(mainScreenContent) + + is MainScreenContent.Normal -> { + if (expectedTbrPercentage != 100) { + // We expected a TBR to be active, but there isn't any; + // we aren't seen any TBR main screen contents. + throw UnexpectedTbrStateException( + expectedTbrPercentage = expectedTbrPercentage, + expectedTbrDuration = expectedTbrDuration, + actualTbrPercentage = 100, + actualTbrDuration = 0 + ) + } + } + + is MainScreenContent.Tbr -> { + if (expectedTbrPercentage == 100) { + // We expected the TBR to be cancelled, but it isn't. + throw UnexpectedTbrStateException( + expectedTbrPercentage = 100, + expectedTbrDuration = 0, + actualTbrPercentage = mainScreenContent.tbrPercentage, + actualTbrDuration = mainScreenContent.remainingTbrDurationInMinutes + ) + } else if ((expectedTbrDuration - mainScreenContent.remainingTbrDurationInMinutes) > 2) { + // The current TBR duration does not match the programmed one. + // We allow a tolerance range of 2 minutes since a little while + // may have passed between setting the TBR and reaching this + // location in the code. + throw UnexpectedTbrStateException( + expectedTbrPercentage = expectedTbrPercentage, + expectedTbrDuration = expectedTbrDuration, + actualTbrPercentage = mainScreenContent.tbrPercentage, + actualTbrDuration = mainScreenContent.remainingTbrDurationInMinutes + ) + } + } + } + } + + /** + * [ProgressReporter] flow for keeping track of the progress of [deliverBolus]. + */ + val bolusDeliveryProgressFlow = bolusDeliveryProgressReporter.progressFlow + + /** + * Instructs the pump to deliver the specified bolus amount. + * + * This function only delivers a standard bolus, no multi-wave / extended ones. + * It is currently not known how to command the Combo to deliver those types. + * + * The function suspends until the bolus was fully delivered or an error occurred. + * In the latter case, an exception is thrown. During the delivery, the current + * status is periodically retrieved from the pump. [bolusStatusUpdateIntervalInMs] + * controls the status update interval. At each update, the bolus state is checked + * (that is, whether it is delivering, or it is done, or an error occurred etc.) + * The bolus amount that was delivered by that point is communicated via the + * [bolusDeliveryProgressFlow]. + * + * To cancel the bolus, simply cancel the coroutine that is suspended by this function. + * + * Prior to the delivery, the number of units available in the reservoir is checked + * by looking at [statusFlow]. If there aren't enough IU in the reservoir, this + * function throws [InsufficientInsulinAvailableException]. + * + * After the delivery, this function looks at the Combo's bolus history delta. That + * delta is expected to contain exactly one entry - the bolus that was just delivered. + * The details in that history delta entry are then emitted as + * [Event.StandardBolusInfused] via [onEvent]. + * If there is no entry, [BolusNotDeliveredException] is thrown. If more than one + * bolus entry is detected, [UnaccountedBolusDetectedException] is thrown (this + * second case is not expected to ever happen, but is possible in theory). The + * history delta is looked at even if an exception is thrown (unless it is one + * of the exceptions that were just mentioned). This is because if there is an + * error _during_ a bolus delivery, then some insulin might have still be + * delivered, and there will be a [Event.StandardBolusInfused] history entry, + * probably just not with the insulin amount that was originally planned. + * It is still important to report that (partial) delivery, which is done + * via [onEvent] just as described above. + * + * Once that is completed, this function calls [updateStatus] to make sure the + * contents of [statusFlow] are up-to-date. A bolus delivery will at least + * change the value of [Status.availableUnitsInReservoir] (unless perhaps it + * is a very small bolus like 0.1 IU, since that value is given in whole IU units). + * + * @param bolusAmount Bolus amount to deliver. Note that this is given + * in 0.1 IU units, so for example, "57" means 5.7 IU. Valid range + * is 0.0 IU to 25.0 IU (that is, integer values 0-250). + * @param bolusReason Reason for this standard bolus. + * @param bolusStatusUpdateIntervalInMs Interval between status updates, + * in milliseconds. Must be at least 1 + * @throws BolusNotDeliveredException if the pump did not deliver the bolus. + * This typically happens because the pump is currently stopped. + * @throws BolusCancelledByUserException when the bolus was cancelled by the user. + * @throws BolusAbortedDueToErrorException when the bolus delivery failed due + * to an error. + * @throws UnaccountedBolusDetectedException if after the bolus delivery + * more than one bolus is reported in the Combo's bolus history delta. + * @throws InsufficientInsulinAvailableException if the reservoir does not + * have enough IUs left for this bolus. + * @throws IllegalArgumentException if [bolusAmount] is not in the 0-250 range, + * or if [bolusStatusUpdateIntervalInMs] is less than 1. + * @throws IllegalStateException if the current state is not + * [State.ReadyForCommands]. + * @throws AlertScreenException if alerts occurs during this call, and they + * aren't a W6 warning (those are handled by this function). + * @throws ExtendedOrMultiwaveBolusActiveException if an extended / multiwave + * bolus is active after delivering this standard bolus. (This should not + * normally happen, since it is not possible for users to set such a bolus + * while also delivering a standard bolus the TBR, but is included for + * completeness.) + */ + suspend fun deliverBolus(bolusAmount: Int, bolusReason: StandardBolusReason, bolusStatusUpdateIntervalInMs: Long = 250) = executeCommand( + // Instruct executeCommand() to not set the mode on its own. + // This function itself switches manually between the + // command and remote terminal modes. + pumpMode = null, + isIdempotent = false, + description = DeliveringBolusCommandDesc(bolusAmount, bolusReason) + ) { + require((bolusAmount > 0) && (bolusAmount <= 250)) { + "Invalid bolus amount $bolusAmount (${bolusAmount.toStringWithDecimal(1)} IU)" + } + require(bolusStatusUpdateIntervalInMs >= 1) { + "Invalid bolus status update interval $bolusStatusUpdateIntervalInMs" + } + + // Check that there's enough insulin in the reservoir. + statusFlow.value?.let { status -> + // Round the bolus amount. The reservoir fill level is given in whole IUs + // by the Combo, but the bolus amount is given in 0.1 IU units. By rounding + // up, we make sure that the check never misses a case where the bolus + // request exceeds the fill level. For example, bolus of 1.3 IU, fill + // level 1 IU, if we just divided by 10 to convert the bolus to whole + // IU units, we'd truncate the 0.3 IU from the bolus, and the check + // would think that it's OK, because the reservoir has 1 IU. If we instead + // round up, any fractional IU will be taken into account correctly. + val roundedBolusIU = (bolusAmount + 9) / 10 + logger(LogLevel.DEBUG) { + "Checking if there is enough insulin in reservoir; reservoir fill level: " + + "${status.availableUnitsInReservoir} IU; bolus amount: ${bolusAmount.toStringWithDecimal(1)} IU" + + "(rounded: $roundedBolusIU IU)" + } + if (status.availableUnitsInReservoir < roundedBolusIU) + throw InsufficientInsulinAvailableException(bolusAmount, status.availableUnitsInReservoir) + } ?: throw IllegalStateException("Cannot deliver bolus without a known pump status") + + // Switch to COMMAND mode for the actual bolus delivery + // and for tracking the bolus progress below. + pumpIO.switchMode(PumpIO.Mode.COMMAND) + + logger(LogLevel.DEBUG) { "Beginning bolus delivery of ${bolusAmount.toStringWithDecimal(1)} IU" } + val didDeliver = pumpIO.deliverCMDStandardBolus(bolusAmount) + if (!didDeliver) { + logger(LogLevel.ERROR) { "Bolus delivery did not commence" } + throw BolusNotDeliveredException(bolusAmount) + } + + bolusDeliveryProgressReporter.reset(Unit) + + logger(LogLevel.DEBUG) { "Waiting until bolus delivery is complete" } + + var bolusFinishedCompletely = false + + // The Combo does not send bolus progress information on its own. Instead, + // we have to regularly poll the current bolus status. Do that in this loop. + // The bolusStatusUpdateIntervalInMs value controls how often we poll. + try { + while (true) { + delay(bolusStatusUpdateIntervalInMs) + + val status = pumpIO.getCMDCurrentBolusDeliveryStatus() + + logger(LogLevel.VERBOSE) { "Got current bolus delivery status: $status" } + + val deliveredAmount = when (status.deliveryState) { + ApplicationLayer.CMDBolusDeliveryState.DELIVERING -> bolusAmount - status.remainingAmount + ApplicationLayer.CMDBolusDeliveryState.DELIVERED -> bolusAmount + ApplicationLayer.CMDBolusDeliveryState.CANCELLED_BY_USER -> { + logger(LogLevel.DEBUG) { "Bolus cancelled by user" } + throw BolusCancelledByUserException( + deliveredAmount = bolusAmount - status.remainingAmount, + totalAmount = bolusAmount + ) + } + ApplicationLayer.CMDBolusDeliveryState.ABORTED_DUE_TO_ERROR -> { + logger(LogLevel.ERROR) { "Bolus aborted due to a delivery error" } + throw BolusAbortedDueToErrorException( + deliveredAmount = bolusAmount - status.remainingAmount, + totalAmount = bolusAmount + ) + } + else -> continue + } + + bolusDeliveryProgressReporter.setCurrentProgressStage( + RTCommandProgressStage.DeliveringBolus( + deliveredAmount = deliveredAmount, + totalAmount = bolusAmount + ) + ) + + if (deliveredAmount >= bolusAmount) { + bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished) + break + } + } + + bolusFinishedCompletely = true + } catch (e: BolusDeliveryException) { + // Handle BolusDeliveryException subclasses separately, + // since these exceptions are thrown when the delivery + // was cancelled by the user or aborted due to an error. + // The code further below tries to cancel in case of any + // exception, which would make no sense with these. + when (e) { + is BolusCancelledByUserException -> + bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled) + else -> + bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e)) + } + throw e + } catch (e: Exception) { + bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e)) + try { + pumpIO.cancelCMDStandardBolus() + } catch (cancelBolusExc: Exception) { + logger(LogLevel.ERROR) { "Silently discarding caught exception while cancelling bolus: $cancelBolusExc" } + } + throw e + } finally { + // After either the bolus is finished or an error occurred, + // check the history delta here. Any bolus entries in the + // delta will be communicated to the outside via the onEvent + // callback. + // Also, if we reach this point after the bolus finished + // successfully (so, bolusFinishedCompletely will be true), + // check for discrepancies in the history delta. We expect + // the delta to contain exactly one StandardBolusInfused + // entry. If there are none, or there are more than one, + // or there are other bolus entries, something isn't right, + // and we throw exceptions. They are _not_ thrown if we reach + // this finally block after an exception occurred above + // though, since in that case, we just want to look at the + // delta to see what happened, whether any (partial) bolus + // was delivered. We still need to communicate such events + // to the outside even if the bolus delivery did not succeed. + + try { + val historyDelta = fetchHistoryDelta() + + if (historyDelta.isEmpty()) { + if (bolusFinishedCompletely) { + logger(LogLevel.ERROR) { "Bolus delivery did not actually occur" } + throw BolusNotDeliveredException(bolusAmount) + } + } else { + var numStandardBolusInfusedEntries = 0 + var unexpectedBolusEntriesDetected = false + scanHistoryDeltaForBolusToEmit( + historyDelta, + reasonForLastStandardBolusInfusion = bolusReason + ) { entry -> + when (val detail = entry.detail) { + is CMDHistoryEventDetail.StandardBolusInfused -> { + numStandardBolusInfusedEntries++ + if (numStandardBolusInfusedEntries > 1) + unexpectedBolusEntriesDetected = true + } + + // We ignore this. It always accompanies StandardBolusInfused. + is CMDHistoryEventDetail.StandardBolusRequested -> + Unit + + else -> { + if (detail.isBolusDetail) + unexpectedBolusEntriesDetected = true + } + } + } + + if (bolusFinishedCompletely) { + if (numStandardBolusInfusedEntries == 0) { + logger(LogLevel.ERROR) { "History delta did not contain an entry about bolus infusion" } + throw BolusNotDeliveredException(bolusAmount) + } else if (unexpectedBolusEntriesDetected) { + logger(LogLevel.ERROR) { "History delta contained unexpected additional bolus entries" } + throw UnaccountedBolusDetectedException() + } + } + } + } finally { + // Re-read pump status. At the very least, the number of available + // IUs in the reservoir will have changed, so we must update the + // status both to make sure that future bolus calls operate with + // an up-to-date status and to let the user know the updated + // reservoir level via the statusFlow. + // We always re-read the pump status, even if the history delta + // checks above detected discrepancies, to make sure the status + // is up-to-date. + // This is done in a loop in case an alert screen appears. If the + // alert is a dismissible warning, we dismiss it, and try again + // (hence the loop). In particular, a W1 low reservoir warning + // can show up if the delivered bolus brought the reservoir level + // below the low threshold. W1 is dismissible. + while (true) { + try { + pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL) + // Not calling updateStatusImpl(), instead calling this directly. + // That's because updateStatusImpl() calls executeCommand(), + // and here, we already are running in a lambda that's run + // by executeCommand(). + updateStatusByReadingMainAndQuickinfoScreens(switchStatesIfNecessary = true) + break + } catch (e: AlertScreenException) { + logger(LogLevel.DEBUG) { "Got alert screen after bolus" } + // If this is a trivial, purely informative, dismissible + // warning screen, this call dismisses it and also emits + // an event if appropriate (like an event about a low + // reservoir level). If this is a non-dismissible warning + // screen or an error screen, this call itself throws + // an AlertScreenException to propagate the alert further. + handleAlertScreenContent(e.alertScreenContent) + } + } + } + } + } + + /** + * Total daily dosage (TDD) history entry. + * + * @property date Date of the TDD. + * @property totalDailyAmount Total amount of insulin used in that day. + * Stored as an integer-encoded-decimal; last 3 digits of that + * integer are the 3 most significant fractional digits of the + * decimal amount. + */ + data class TDDHistoryEntry(val date: Instant, val totalDailyAmount: Int) + + /** + * [ProgressReporter] flow for keeping track of the progress of [fetchTDDHistory]. + */ + val tddHistoryProgressFlow = tddHistoryProgressReporter.progressFlow + + /** + * Fetches the TDD history. + * + * This suspends the calling coroutine until the entire TDD history + * is fetched, an error occurs, or the coroutine is cancelled. + * + * @throws IllegalStateException if the current state is not + * [State.ReadyForCommands]. + * @throws AlertScreenException if alerts occurs during this call, and + * they aren't a W6 warning (those are handled by this function). + */ + suspend fun fetchTDDHistory() = executeCommand>( + pumpMode = PumpIO.Mode.REMOTE_TERMINAL, + isIdempotent = true, + description = FetchingTDDHistoryCommandDesc() + ) { + tddHistoryProgressReporter.reset(Unit) + + try { + val tddHistoryEntries = mutableListOf() + + val currentSystemDateTime = Clock.System.now() + val currentSystemTimeZone = TimeZone.currentSystemDefault() + val currentLocalDate = currentSystemDateTime.toLocalDateTime(currentSystemTimeZone).date + + fun processTDDScreen(dailyTotalsScreen: ParsedScreen.MyDataDailyTotalsScreen) { + val historyEntry = TDDHistoryEntry( + // Fix the date since the Combo does not show years in TDD screens. + date = dailyTotalsScreen.date.withFixedYearFrom(currentLocalDate).atStartOfDayIn(currentPumpUtcOffset!!.asTimeZone()), + totalDailyAmount = dailyTotalsScreen.totalDailyAmount + ) + + logger(LogLevel.DEBUG) { + "Got TDD history entry ${dailyTotalsScreen.index} / ${dailyTotalsScreen.totalNumEntries} ; " + + "date = ${historyEntry.date} ; " + + "TDD = ${historyEntry.totalDailyAmount.toStringWithDecimal(3)}" + } + + tddHistoryEntries.add(historyEntry) + + tddHistoryProgressReporter.setCurrentProgressStage( + RTCommandProgressStage.FetchingTDDHistory(dailyTotalsScreen.index, dailyTotalsScreen.totalNumEntries) + ) + } + + // Navigate to the TDD screens and process the very first shown TDD screen. We + // process the first screeen separately since the longPressRTButtonUntil() function + // uses the supplied block as a predicate to check if it should _continue_ pressing + // the button, meaning that it will always press the button at least initially, + // moving to entry #2 in the TDD history. Thus, if we don't look at the screen now, + // we miss entry #1, which is the current day. + val firstTDDScreen = navigateToRTScreen(rtNavigationContext, ParsedScreen.MyDataDailyTotalsScreen::class, pumpSuspended) as ParsedScreen.MyDataDailyTotalsScreen + processTDDScreen(firstTDDScreen) + + longPressRTButtonUntil(rtNavigationContext, RTNavigationButton.DOWN) { parsedScreen -> + if (parsedScreen !is ParsedScreen.MyDataDailyTotalsScreen) { + logger(LogLevel.DEBUG) { "Got a non-TDD screen ($parsedScreen) ; stopping TDD history scan" } + return@longPressRTButtonUntil LongPressRTButtonsCommand.ReleaseButton + } + + processTDDScreen(parsedScreen) + + return@longPressRTButtonUntil if (parsedScreen.index >= parsedScreen.totalNumEntries) + LongPressRTButtonsCommand.ReleaseButton + else + LongPressRTButtonsCommand.ContinuePressingButton + } + + return@executeCommand tddHistoryEntries + } catch (e: CancellationException) { + tddHistoryProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled) + throw e + } catch (e: Exception) { + tddHistoryProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e)) + throw e + } + } + + /** + * Updates the value of [statusFlow]. + * + * This can be called by the user in the [State.Suspended] and [State.ReadyForCommands] + * states. Additionally, the status is automatically updated by [connect] + * and after [deliverBolus] finishes (both if bolus delivery succeeds and + * if an exception is thrown by that function). This reads information from + * the main screen and the quickinfo screen, so it should not be called more + * than necessary, since reading remote terminal screens takes some time. + * + * @throws IllegalStateException if the current state is not + * [State.Suspended] or [State.ReadyForCommands]. + * @throws AlertScreenException if alerts occurs during this call, and + * they aren't a W6 warning (those are handled by this function). + * @throws ExtendedOrMultiwaveBolusActiveException if an extended / multiwave + * bolus is active (these are shown on the main screen). + */ + suspend fun updateStatus() = updateStatusImpl( + allowExecutionWhileSuspended = true, + allowExecutionWhileChecking = false, + switchStatesIfNecessary = true + ) + + // The functions below are not part of the normal Pump API. They instead + // are meant for interactive test applications whose UI contains widgets + // for pressing the UP button etc. See PumpIO for a documentation of + // what these functions do. + + suspend fun sendShortRTButtonPress(buttons: List) { + pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL) + pumpIO.sendShortRTButtonPress(buttons) + } + + suspend fun sendShortRTButtonPress(button: ApplicationLayer.RTButton) = + sendShortRTButtonPress(listOf(button)) + + suspend fun startLongRTButtonPress(buttons: List, keepGoing: (suspend () -> Boolean)? = null) { + pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL) + pumpIO.startLongRTButtonPress(buttons, keepGoing) + } + + suspend fun startLongRTButtonPress(button: ApplicationLayer.RTButton, keepGoing: (suspend () -> Boolean)? = null) = + startLongRTButtonPress(listOf(button), keepGoing) + + suspend fun stopLongRTButtonPress() = + pumpIO.stopLongRTButtonPress() + + suspend fun waitForLongRTButtonPressToFinish() = + pumpIO.waitForLongRTButtonPressToFinish() + + suspend fun switchMode(mode: PumpIO.Mode) = + pumpIO.switchMode(mode) + + /************************************* + *** PRIVATE FUNCTIONS AND CLASSES *** + *************************************/ + + private fun processDisplayFrame(displayFrame: DisplayFrame?) = + parsedDisplayFrameStream.feedDisplayFrame(displayFrame) + + private fun packetReceiverExceptionThrown(e: TransportLayer.PacketReceiverException) { + parsedDisplayFrameStream.abortDueToError(e) + } + + private inline fun createBasalProgressReporter() = + ProgressReporter( + listOf( + ProgressStageSubtype::class + ), + Unit + ) { _: Int, _: Int, stage: ProgressStage, _: Unit -> + // Basal profile access progress is determined by the single + // stage in the reporter, which is SettingBasalProfile or + // GettingBasalProfile. That stage contains how many basal + // profile factors have been accessed so far, which is + // suitable for a progress indicator, so we use that for + // the overall progress. + when (stage) { + BasicProgressStage.Finished, + is BasicProgressStage.Aborted -> 1.0 + is RTCommandProgressStage.SettingBasalProfile -> + stage.numSetFactors.toDouble() / NUM_COMBO_BASAL_PROFILE_FACTORS.toDouble() + is RTCommandProgressStage.GettingBasalProfile -> + stage.numSetFactors.toDouble() / NUM_COMBO_BASAL_PROFILE_FACTORS.toDouble() + else -> 0.0 + } + } + + private fun setState(newState: State) { + val oldState = _stateFlow.value + + if (oldState == newState) + return + + _stateFlow.value = newState + + logger(LogLevel.DEBUG) { "Setting Combo driver state: old: $oldState new: $newState" } + } + + private suspend fun executeCommand( + pumpMode: PumpIO.Mode?, + isIdempotent: Boolean, + description: CommandDescription, + allowExecutionWhileSuspended: Boolean = false, + allowExecutionWhileChecking: Boolean = false, + block: suspend CoroutineScope.() -> T + ): T { + check( + (stateFlow.value == State.ReadyForCommands) || + (allowExecutionWhileSuspended && (stateFlow.value == State.Suspended)) || + (allowExecutionWhileChecking && (stateFlow.value == State.CheckingPump)) + ) { "Cannot execute command in the ${stateFlow.value} state" } + + val previousState = stateFlow.value + if (stateFlow.value != State.CheckingPump) + setState(State.ExecutingCommand(description)) + + try { + // Verify that there have been no errors/warnings since the last time + // a command was executed. The Combo is not capable of pushing a + // notification to ComboCtl. Instead, ComboCtl has to check for the + // presence of command mode error/warning flags and/or look for the + // presence of alert screens manually. + checkForAlerts() + + var retval: T? = null + + var needsToReconnect = false + + // Reset these to guarantee that the handleAlertScreenContent() + // calls don't use stale states. + rtScreenAlreadyDismissed = false + lastObservedAlertScreenContent = null + + // A command execution is attempted a number of times. That number + // depends on whether it is an idempotent command. If it is, then + // it is possible to retry multiple times if command execution + // failed due to certain specific exceptions. (Any other exceptions + // are just rethrown; no more attempts are made then.) + var attemptNr = 0 + val maxNumAttempts = if (isIdempotent) NUM_IDEMPOTENT_COMMAND_DISPATCH_ATTEMPTS else 1 + var doAlertCheck = false + var commandSucceeded = false + + while (!commandSucceeded && (attemptNr < maxNumAttempts)) { + try { + if (needsToReconnect) { + // Wait a while before attempting to reconnect. IO failure + // typically happens due to Bluetooth problems (including + // non-technical ones like when the pump is out of reach) + // and pump specific cases like when the user presses a + // button on the pump and enables its local UI (this + // terminates the Bluetooth connection). In these cases, + // it is useful to wait a bit to give the pump and/or the + // Bluetooth stack some time to recover. This also + // prevents busy loops that use 100% CPU. + delay(DELAY_IN_MS_BETWEEN_COMMAND_DISPATCH_ATTEMPTS) + reconnect() + // Check for alerts right after reconnect since the earlier + // disconnect may have produced an alert. For example, if + // a TBR was being set, and the pump got disconnected, a + // W6 alert will have been triggered. + checkForAlerts() + needsToReconnect = false + logger(LogLevel.DEBUG) { "Pump successfully reconnected" } + } + + if (pumpMode != null) + pumpIO.switchMode(pumpMode) + + retval = coroutineScope { + block.invoke(this) + } + + doAlertCheck = true + + commandSucceeded = true + } catch (e: CancellationException) { + // Do this check after cancelling, since when some commands + // are cancelled (like a TBR for example), warnings can appear. + doAlertCheck = true + throw e + } catch (e: AlertScreenException) { + logger(LogLevel.DEBUG) { "Alert occurred during command execution" } + // We enter this catch block if any alert screens appear + // _during_ the command execution. (doAlertCheck is about + // alerts that happen _after_ command execution, like a W6 + // that appears after setting a 100% TBR.) In such a case, + // the command is considered aborted, and we have to try again + // (if isIdempotent is set to true). + handleAlertScreenContent(e.alertScreenContent) + } catch (e: TransportLayer.PacketReceiverException) { + // When the pump terminates the connection, this can happen either + // through an ErrorCodeException (if the pump sends a packet with an + // error code), or through a ComboIOException (in case of IO errors + // because the Combo terminated the connection). Interpret both as a + // "pump terminated connection" case to initiate a reconnect attempt. + val pumpTerminatedConnection = when (val it = e.cause) { + is ApplicationLayer.ErrorCodeException -> it.appLayerPacket.command == ApplicationLayer.Command.CTRL_DISCONNECT + is ComboIOException -> true + else -> false + } + + // Packet receiver exceptions can happen for a number of reasons. + // To be on the safe side, we only try to reconnect if the exception + // happened due to the Combo terminating the connection on its end. + + if (pumpTerminatedConnection) { + if (!reconnectAttemptsEnabled) { + logger(LogLevel.DEBUG) { + "Pump terminated connection, and reconnect attempts are currently disabled" + } + throw e + } else if (isIdempotent) { + logger(LogLevel.DEBUG) { "Pump terminated connection; will try to reconnect since this is an idempotent command" } + needsToReconnect = true + } else { + logger(LogLevel.DEBUG) { + "Pump terminated connection, but will not try to reconnect since this is a non-idempotent command" + } + throw e + } + } else + throw e + } catch (e: ComboIOException) { + // IO exceptions typically happen because of connection failure. + // This includes cases like when the pump and phone are out of + // reach. Try to reconnect if this is an idempotent command. + + if (!reconnectAttemptsEnabled) { + logger(LogLevel.DEBUG) { + "Combo IO exception $e occurred, but reconnect attempts are currently disabled" + } + throw e + } else if (isIdempotent) { + logger(LogLevel.DEBUG) { "Combo IO exception $e occurred; will try to reconnect since this is an idempotent command" } + needsToReconnect = true + } else { + // Don't bother if this command is not idempotent, since in that + // case, we can only perform one single attempt anyway. + logger(LogLevel.DEBUG) { + "Combo IO exception $e occurred, but will not try to reconnect since this is a non-idempotent command" + } + throw e + } + } finally { + if (doAlertCheck) { + logger(LogLevel.DEBUG) { "Checking for post-execution alerts" } + // Post-command check in case something went wrong + // and an alert screen appeared after the command ran. + // Most commonly, these are benign warning screens, + // especially W6, W7, W8. + // Using a NonCancellable context in case the command + // was aborted by cancellation (like a cancelled bolus). + // Without this context, the checkForAlerts() call would + // not actually do anything. + withContext(NonCancellable) { + checkForAlerts() + } + } + } + + attemptNr++ + } + + if (commandSucceeded) { + setState(previousState) + // retval is non-null precisely when the command succeeded. + return retval!! + } else throw CommandExecutionAttemptsFailedException() + } catch (e: CancellationException) { + // Command was cancelled. Revert to the previous state (since cancellation + // is not an error), then rethrow the CancellationException to maintain + // structured concurrency. + setState(previousState) + throw e + } catch (e: AlertScreenException) { + if (e.alertScreenContent is AlertScreenContent.Error) { + // If we reach this point, then an alert screen with an error + // code showed up. That screen was dismissed and an exception + // was thrown to inform us about that error. Importantly, after + // such an error screen, the Combo automatically switches to + // its stopped (= suspended) state. And during this state, + // the Combo suspends all insulin delivery, effectively behaving + // like a 0% TBR. Report this state as such to the caller + // via onEvent(). + reportPumpSuspendedTbr() + } + setState(State.Error(throwable = e, "Unhandled alert screen during command execution")) + throw e + } catch (t: Throwable) { + setState(State.Error(throwable = t, "Command execution error")) + throw t + } + } + + // This is separate from updateStatus() to prevent that call from + // being made during the CHECKING state by the user. + // Internally, we sometimes have to update the status during that + // state, and this is why this function exists - internal status + // updates are then done by calling this instead (with + // allowExecutionWhileChecking set to true). + private suspend fun updateStatusImpl( + allowExecutionWhileSuspended: Boolean, + allowExecutionWhileChecking: Boolean, + switchStatesIfNecessary: Boolean + ) = executeCommand( + pumpMode = PumpIO.Mode.REMOTE_TERMINAL, + isIdempotent = true, + allowExecutionWhileSuspended = allowExecutionWhileSuspended, + allowExecutionWhileChecking = allowExecutionWhileChecking, + description = UpdatingPumpStatusCommandDesc() + ) { + updateStatusByReadingMainAndQuickinfoScreens(switchStatesIfNecessary) + } + + private suspend fun checkForAlerts() { + // Alert checks differ depending on the currently active mode. + // That's because we want to avoid unnecessary mode changes + // that take extra time to complete. + // + // If we are in the command mode, then we can right away send + // a CMD_READ_ERROR_WARNING_STATUS packet to see if an error + // and/or warning is active right now. Only if one is active + // do we switch to the remote terminal mode to read the alert + // screen contents and dismiss the screen (if appropriate). + // + // If we are in the remote terminal mode, sending the + // CMD_READ_ERROR_WARNING_STATUS packet would require switching + // to the command mode first. In this situation, it is instead + // easier and quicker to just peek at the current RT display frame + // and check if it shows an alert screen. If so, read its contents + // and dismiss it (if appropriate). + if (currentModeFlow.value == PumpIO.Mode.COMMAND) { + val pumpStatus = pumpIO.readCMDErrorWarningStatus() + if (pumpStatus.warningOccurred || pumpStatus.errorOccurred) { + pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL) + handleAlertScreen() + } + } else { + while (true) { + // Loop until we get a non-blinked out screen (when blinked out, + // getParsedDisplayFrame() returns null). + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame( + filterDuplicates = false, + processAlertScreens = false + ) ?: continue + + // If the pump indeed is currently showing an alert screen, + // handle it, passing the already seen screen to handleAlertScreen() + // to be able to analyze that immediately. If no such alert screen + // is shown however, reset the duplicate detection - subsequent + // calls may want to call getParsedDisplayFrame() with filterDuplicates + // set to true, which would cause that function call to hang, since + // the rtNavigationContext would store the already seen screen for + // detecting duplicates. + val parsedScreen = parsedDisplayFrame.parsedScreen + if (parsedScreen is ParsedScreen.AlertScreen) + handleAlertScreen(parsedScreen) + else + rtNavigationContext.resetDuplicate() + + break + } + } + } + + private suspend fun handleAlertScreen(previouslySeenAlertScreen: ParsedScreen.AlertScreen? = null) { + // previouslySeenAlertScreen is a way for the caller to pass an alert + // screen to here that the caller already observed. This allows us here + // to skip a getParsedDisplayFrame() call during the first iteration + // of this loop. That call would be redundant, since the first time + // we arrive here, the caller may already know that an alert screen + // appeared. Thus, if previouslySeenAlertScreen is not null, use its + // contents during the first iteration instead of getting a parsed + // display frame from the rtNavigationContext. But only do this during + // the first iteration, since handleAlertScreenContent() causes + // changes that also cause the RT screen to change. + var previouslySeenAlertScreenInternal = previouslySeenAlertScreen + while (true) { + val alertScreenContent = if (previouslySeenAlertScreenInternal != null) { + val content = previouslySeenAlertScreenInternal.content + previouslySeenAlertScreenInternal = null + content + } else { + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame( + filterDuplicates = true, + processAlertScreens = false + ) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + + if (parsedScreen !is ParsedScreen.AlertScreen) + break + else + parsedScreen.content + } + + logger(LogLevel.DEBUG) { + "Got alert screen with content $alertScreenContent" + } + handleAlertScreenContent(alertScreenContent) + } + } + + private suspend fun handleAlertScreenContent(alertScreenContent: AlertScreenContent) { + when (alertScreenContent) { + // Alert screens blink. When the content is "blinked out", + // the warning/error code is hidden, and the screen contents + // cannot be recognized. We just ignore such blinked-out alert + // screens, since they are not an error. The next time + // handleAlertScreenContent() is called, we hopefully + // get recognizable content. + is AlertScreenContent.None -> Unit + + // Error screen contents always cause a rethrow since all error + // screens are considered non-recoverable errors that must not + // be ignored / dismissed. Instead, let the code fail by rethrowing + // the exception. The user needs to check out the error manually. + is AlertScreenContent.Error -> throw AlertScreenException(alertScreenContent) + + is AlertScreenContent.Warning -> { + // Check if the alert screen content changed in case + // several warnings appear one after the other. In + // such a case, we need to reset the dismissal count + // to be able to properly dismiss followup warnings. + if (lastObservedAlertScreenContent != alertScreenContent) { + lastObservedAlertScreenContent = alertScreenContent + rtScreenAlreadyDismissed = false + } + + val warningCode = alertScreenContent.code + + // W1 is the "reservoir almost empty" warning. Notify the caller + // about this, then dismiss it. + // W2 is the "battery almost empty" warning. Notify the caller + // about this, then dismiss it. + // W6 informs about an aborted TBR. + // W7 informs about a finished TBR. (This warning can be turned + // off permanently through the Accu-Check 360 software, but + // in case it wasn't turned off, we still handle it here.) + // W8 informs about an aborted bolus. + // W3 alerts that date and time need to be reviewed. + // W6, W7, W8 are purely informational, and can be dismissed + // and ignored. + // Any other warnings are intentionally rethrown for safety. + when (warningCode) { + 1 -> onEvent(Event.ReservoirLow) + 2 -> onEvent(Event.BatteryLow) + 3, 6, 7, 8 -> Unit + else -> throw AlertScreenException(alertScreenContent) + } + + // Warning screens are dismissed by pressing CHECK twice. + // First time, the CHECK button press transitions the state + // on that screen from alert to confirm. Second time, the + // screen is finally dismissed. This holds true even if + // the screen blinks in between; the Combo still registers + // the two button presses, so there is no need to wait + // for the second screen - just press twice right away. + if (!rtScreenAlreadyDismissed) { + logger(LogLevel.DEBUG) { "Dismissing W$warningCode by short-pressing CHECK twice" } + rtNavigationContext.shortPressButton(RTNavigationButton.CHECK) + rtNavigationContext.shortPressButton(RTNavigationButton.CHECK) + rtScreenAlreadyDismissed = true + } + } + } + } + + // The actual connect function. This has no exception handling or reconnect + // logic, and that is on purpose. This is the internal connect logic that + // callers then surround with their error handling code. + private suspend fun connectInternal() { + // Prevent reconnect() from being called if post-connect check commands + // fail (like the command to read the basal profile), since this internal + // connect function is explicitly meant to _not_ do things like attempting + // to reconnect in case of failures - that's up to the callers. + reconnectAttemptsEnabled = false + + setState(State.Connecting) + + try { + // Get the current pump state UTC offset to translate localtime + // timestamps from the history delta to Instant timestamps. + currentPumpUtcOffset = pumpStateStore.getCurrentUtcOffset(bluetoothDevice.address) + + // Set the command mode as the initial mode to be able + // to directly check for warnings / errors through the + // CMD_READ_PUMP_STATUS command. + pumpIO.connect(initialMode = PumpIO.Mode.COMMAND, runHeartbeat = true, connectProgressReporter = connectProgressReporter) + connectProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished) + + setState(State.CheckingPump) + performOnConnectChecks() + } finally { + reconnectAttemptsEnabled = true + } + + setState(if (pumpSuspended) State.Suspended else State.ReadyForCommands) + } + + // Utility code to add a log line that specifically records + // that this is a *re*connect attempt. + private suspend fun reconnect() { + logger(LogLevel.DEBUG) { "Reconnecting Combo with address ${bluetoothDevice.address}" } + disconnect() + connectProgressReporter.reset(Unit) + connectInternal() + } + + // The block allows callers to perform their own processing for each + // history delta entry, for example to check for unaccounted boluses. + private fun scanHistoryDeltaForBolusToEmit( + historyDelta: List, + reasonForLastStandardBolusInfusion: StandardBolusReason = StandardBolusReason.NORMAL, + block: (historyEntry: ApplicationLayer.CMDHistoryEvent) -> Unit = { } + ) { + var lastBolusId = 0L + var lastBolusAmount = 0 + var lastBolusInfusionTimestamp: Instant? = null + var lastStandardBolusRequestedTypeSet = false + var lastStandardBolusInfusedTypeSet = false + + // Traverse the history delta in reverse order. The last entries + // are the most recent ones, and we are particularly interested + // in details about the last (standard) bolus. By traversing + // in reverse, we encounter the last (standard) bolus first. + historyDelta.reversed().onEach { entry -> + block(entry) + + val timestamp = entry.timestamp.toInstant(currentPumpUtcOffset!!) + + when (val detail = entry.detail) { + is CMDHistoryEventDetail.QuickBolusRequested -> + onEvent(Event.QuickBolusRequested( + bolusId = entry.eventCounter, + timestamp = timestamp, + bolusAmount = detail.bolusAmount + )) + is CMDHistoryEventDetail.QuickBolusInfused -> { + onEvent(Event.QuickBolusInfused( + bolusId = entry.eventCounter, + timestamp = timestamp, + bolusAmount = detail.bolusAmount + )) + if (lastBolusInfusionTimestamp == null) { + lastBolusId = entry.eventCounter + lastBolusAmount = detail.bolusAmount + lastBolusInfusionTimestamp = timestamp + } + } + is CMDHistoryEventDetail.StandardBolusRequested -> { + val standardBolusReason = + if (lastStandardBolusRequestedTypeSet) StandardBolusReason.NORMAL else reasonForLastStandardBolusInfusion + onEvent(Event.StandardBolusRequested( + bolusId = entry.eventCounter, + timestamp = timestamp, + manual = detail.manual, + bolusAmount = detail.bolusAmount, + standardBolusReason = standardBolusReason + )) + lastStandardBolusRequestedTypeSet = true + } + is CMDHistoryEventDetail.StandardBolusInfused -> { + val standardBolusReason = + if (lastStandardBolusInfusedTypeSet) StandardBolusReason.NORMAL else reasonForLastStandardBolusInfusion + onEvent(Event.StandardBolusInfused( + bolusId = entry.eventCounter, + timestamp = timestamp, + manual = detail.manual, + bolusAmount = detail.bolusAmount, + standardBolusReason = standardBolusReason + )) + lastStandardBolusInfusedTypeSet = true + if (lastBolusInfusionTimestamp == null) { + lastBolusId = entry.eventCounter + lastBolusAmount = detail.bolusAmount + lastBolusInfusionTimestamp = timestamp + } + } + is CMDHistoryEventDetail.ExtendedBolusStarted -> + onEvent(Event.ExtendedBolusStarted( + bolusId = entry.eventCounter, + timestamp = timestamp, + totalBolusAmount = detail.totalBolusAmount, + totalDurationMinutes = detail.totalDurationMinutes + )) + is CMDHistoryEventDetail.ExtendedBolusEnded -> { + onEvent(Event.ExtendedBolusEnded( + bolusId = entry.eventCounter, + timestamp = timestamp, + totalBolusAmount = detail.totalBolusAmount, + totalDurationMinutes = detail.totalDurationMinutes + )) + } + is CMDHistoryEventDetail.MultiwaveBolusStarted -> + onEvent(Event.MultiwaveBolusStarted( + bolusId = entry.eventCounter, + timestamp = timestamp, + totalBolusAmount = detail.totalBolusAmount, + immediateBolusAmount = detail.immediateBolusAmount, + totalDurationMinutes = detail.totalDurationMinutes + )) + is CMDHistoryEventDetail.MultiwaveBolusEnded -> { + onEvent(Event.MultiwaveBolusEnded( + bolusId = entry.eventCounter, + timestamp = timestamp, + totalBolusAmount = detail.totalBolusAmount, + immediateBolusAmount = detail.immediateBolusAmount, + totalDurationMinutes = detail.totalDurationMinutes + )) + } + else -> Unit + } + } + + if (lastBolusInfusionTimestamp != null) { + // The last bolus timestamp may be a little bit in advance + // of the current time. That's because the datetime is set + // through the remote terminal mode, which is slow, which + // is why the updatePumpDateTime() call that is done inside + // performOnConnectChecks() gets passed a timestamp that is + // not the current time, but the current time + 30 seconds. + // This compensates for the time it takes to set the new + // datetime. But as a side effect, the bolus timestamps may + // be ahead of the current time. In such cases, compensate + // for that by using the current time instead. + val now = Clock.System.now() + val bolusTimestamp = lastBolusInfusionTimestamp!!.let { + if (now < it) now else it + } + + val lastBolus = LastBolus( + bolusId = lastBolusId, + bolusAmount = lastBolusAmount, + timestamp = bolusTimestamp + ) + + logger(LogLevel.DEBUG) { + "Found a last bolus in history delta; details: $lastBolus; now: $now; " + + "lastBolusInfusionTimestamp: $lastBolusInfusionTimestamp -> bolusTimestamp: $bolusTimestamp" + } + + _lastBolusFlow.value = lastBolus + } else + logger(LogLevel.DEBUG) { "No last bolus found in history delta" } + } + + private suspend fun performOnConnectChecks() { + require(currentPumpUtcOffset != null) + + // First few operations will run in command mode. + pumpIO.switchMode(PumpIO.Mode.COMMAND) + + // Read history delta, quickinfo etc. as a preparation + // for further evaluating the current pump state. + val historyDelta = fetchHistoryDelta() + + // This reads information from the main screen and quickinfo screen. + // Don't switch states. The caller does that. + updateStatusImpl( + allowExecutionWhileSuspended = true, + allowExecutionWhileChecking = true, + switchStatesIfNecessary = false + ) + + // Read the timestamp when the update is read to be able to determine + // below what factor of the current basal profile corresponds to the + // factor we see on screen. This is distinct from the other datetimes + // we fetch later below, since several operations are in between here + // and there, and these operations can take some time to finish. + // Since this is done through the command mode, we must make sure we + // are in that mode before reading the datetime. + pumpIO.switchMode(PumpIO.Mode.COMMAND) + val timestampOfStatusUpdate = pumpIO.readCMDDateTime() + + // Scan history delta for unaccounted bolus(es). Report all discovered ones. + scanHistoryDeltaForBolusToEmit(historyDelta) + + if (pumpSuspended) { + // If the pump is suspended, no insulin is delivered. This behaves like + // a 0% TBR. Announce such a "fake 0% TBR" via onEvent to allow the + // caller to keep track of these no-delivery situations. + reportPumpSuspendedTbr() + } else { + // Get the current TBR state as recorded in the pump state store, then + // retrieve the current status that was updated above by the updateStatusImpl() + // call. The status gives us information about what's on the main screen. + // If a TBR is currently ongoing, it will show up on the main screen. + val currentTbrState = pumpStateStore.getCurrentTbrState(bluetoothDevice.address) + val status = statusFlow.value + require(status != null) + + // Handle the following four cases: + // + // 1. currentTbrState is TbrStarted, and no TBR information is shown on the main screen. + // Since currentTbrState indicates a started TBR, and the main screen no longer shows an + // active TBR, this means that the TBR ended some time ago. Announce the ended TBR as an + // event, then set currentTbrState to NoTbrOngoing. + // 2. currentTbrState is TbrStarted, and TBR information is shown on the main screen. + // Check that the TBR information on screen matches the TBR information from the + // TbrStarted state. If there is a mismatch, emit an UnknownTbrDetected event. + // Otherwise, do nothing in that case other than a currentTbrFlow value update, since + // we know the TBR started earlier and is still ongoing. + // 3. currentTbrState is NoTbrOngoing, and no TBR information is shown on the main screen. + // Do nothing in that case other than a currentTbrFlow value update, since we already + // know that no TBR was ongoing. + // 4. currentTbrState is NoTbrOngoing, and TBR information is shown on the main screen. + // This is an error - a TBR is ongoing that we don't know about. We did not start it! + // End it immediately, then emit an UnknownTbrDetected event to inform the user about + // this unexpected TBR. Ideally, this exception leads to an alert shown on the UI. + // Also, in this case, we do a hard TBR cancel, which triggers W6, but this is an unusual + // situation, so the extra vibration is okay. + // + // NOTE: When no TBR information is shown on the main screen, the status.tbrPercentage is + // always set to 100. When there's TBR information, it is always something other than 100. + + val tbrInfoShownOnMainScreen = (status.tbrPercentage != 100) + + when (currentTbrState) { + is CurrentTbrState.TbrStarted -> { + if (!tbrInfoShownOnMainScreen) { + // Handle case #1. + + val now = Clock.System.now() + val currentTbr = currentTbrState.tbr + val currentTbrDuration = currentTbr.durationInMinutes.toDuration(DurationUnit.MINUTES) + val (endTbrTimestamp, newDurationInMinutes) = if ((now - currentTbr.timestamp) > currentTbrDuration) + Pair(currentTbr.timestamp + currentTbrDuration, currentTbr.durationInMinutes) + else + Pair(now, (now - currentTbr.timestamp).inWholeMinutes.toInt()) + + val newTbr = Tbr( + timestamp = currentTbr.timestamp, + percentage = currentTbr.percentage, + durationInMinutes = newDurationInMinutes, + currentTbr.type + ) + logger(LogLevel.DEBUG) { "Previously started TBR ended; TBR: $newTbr" } + pumpStateStore.setCurrentTbrState(bluetoothDevice.address, CurrentTbrState.NoTbrOngoing) + onEvent(Event.TbrEnded(newTbr, endTbrTimestamp)) + _currentTbrFlow.value = null + } else { + // Handle case #2. + + val now = Clock.System.now() + val expectedCurrentTbrPercentage = currentTbrState.tbr.percentage + val actualCurrentTbrPercentage = status.tbrPercentage + val elapsedTimeSinceTbrStart = now - currentTbrState.tbr.timestamp + val expectedRemainingDurationInMinutes = currentTbrState.tbr.durationInMinutes - elapsedTimeSinceTbrStart.inWholeMinutes.toInt() + val actualRemainingDurationInMinutes = status.remainingTbrDurationInMinutes + + // The remaining duration check uses a tolerance range of 10 minutes, since + // TBR durations are set in 15-minute steps, and a strict value equality check + // would raise false positives due to jitter caused by using the current time. + if ((expectedCurrentTbrPercentage != actualCurrentTbrPercentage) || + ((expectedRemainingDurationInMinutes - actualRemainingDurationInMinutes).absoluteValue >= 10)) { + logger(LogLevel.DEBUG) { + "Unknown/unexpected TBR detected; expected TBR with percentage $expectedCurrentTbrPercentage " + + "and remaining duration expectedRemainingDurationInMinutes; actual TBR has percentage " + + "$actualRemainingDurationInMinutes and remaining duration $actualRemainingDurationInMinutes" + } + + pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL) + setCurrentTbr(percentage = 100, durationInMinutes = 0) + + onEvent(Event.UnknownTbrDetected( + tbrPercentage = status.tbrPercentage, + remainingTbrDurationInMinutes = status.remainingTbrDurationInMinutes + )) + } + + _currentTbrFlow.value = currentTbrState.tbr + } + } + + is CurrentTbrState.NoTbrOngoing -> { + if (!tbrInfoShownOnMainScreen) { + // Handle case #3. + _currentTbrFlow.value = null + } else { + // Handle case #4. + + logger(LogLevel.DEBUG) { + "Unknown TBR detected with percentage ${status.tbrPercentage} " + + "and remaining duration ${status.remainingTbrDurationInMinutes}; " + + "aborting this TBR" + } + + pumpIO.switchMode(PumpIO.Mode.REMOTE_TERMINAL) + setCurrentTbr(percentage = 100, durationInMinutes = 0) + + onEvent(Event.UnknownTbrDetected( + tbrPercentage = status.tbrPercentage, + remainingTbrDurationInMinutes = status.remainingTbrDurationInMinutes + )) + } + } + } + } + + // Make sure that (a) we have a known current basal profile and + // (b) that any existing current basal profile is valid. + if (currentBasalProfile == null) { + logger(LogLevel.DEBUG) { "No current basal profile known; reading the pump's profile now" } + currentBasalProfile = getBasalProfile() + } else { + // Compare the basal factor shown on the RT main screen against the current + // factor from the basal profile. If we detect a mismatch, then the profile + // that is stored in currentBasalProfile is incorrect and needs to be read + // from the pump. + val currentBasalRateFactor = statusFlow.value?.currentBasalRateFactor ?: 0 + if (currentBasalRateFactor != 0) { + var currentFactorFromProfile = currentBasalProfile!![timestampOfStatusUpdate.hour] + logger(LogLevel.DEBUG) { + "Current basal rate factor according to profile: $currentFactorFromProfile; current one" + + " according to pump: $currentBasalRateFactor" + } + + // We don't read the profile from the pump right away, and instead retry + // the check. This is because of an edge case: If we happen to check for + // a mismatch at the same moment when the next hour starts and the pump + // moves on to the next basal rate factor, we might have gotten a current + // pump time that corresponds to one hour and a factor on screen that + // corresponds to another hour, leading to a false mismatch. The solution + // is to fetch again the pump's current datetime and retry the check. + // If there is again a mismatch, then it is a real one. + if (currentBasalRateFactor != currentFactorFromProfile) { + logger(LogLevel.DEBUG) { "Factors do not match; checking again" } + + val currentPumpTime = pumpIO.readCMDDateTime() + currentFactorFromProfile = currentBasalProfile!![currentPumpTime.hour] + + if (currentBasalRateFactor != currentFactorFromProfile) { + logger(LogLevel.DEBUG) { "Second check showed again a factor mismatch; reading basal profile" } + currentBasalProfile = getBasalProfile() + } + } + } + } + + // The next datetime operations will run in command mode again. + pumpIO.switchMode(PumpIO.Mode.COMMAND) + + // Get current pump and system datetime _after_ all operations above + // finished in case those operations take some time to finish. We need + // the datetimes to be as current as possible for the checks below. + val currentPumpLocalDateTime = pumpIO.readCMDDateTime() + val currentPumpDateTime = currentPumpLocalDateTime.toInstant(currentPumpUtcOffset!!) + val currentSystemDateTime = Clock.System.now() + val currentSystemTimeZone = TimeZone.currentSystemDefault() + val currentSystemUtcOffset = currentSystemTimeZone.offsetAt(currentSystemDateTime) + val dateTimeDelta = (currentSystemDateTime - currentPumpDateTime) + + logger(LogLevel.DEBUG) { "History delta size: ${historyDelta.size}" } + logger(LogLevel.DEBUG) { "Pump local datetime: $currentPumpLocalDateTime with UTC offset: $currentPumpDateTime" } + logger(LogLevel.DEBUG) { "Current system datetime: $currentSystemDateTime" } + logger(LogLevel.DEBUG) { "Datetime delta: $dateTimeDelta" } + + // The following checks update the UTC offset in the pump state and + // the datetime in the pump. This is done *after* all the checks above + // because all the timestamps that we read from the pump's history delta + // used a localtime that was tied to the current UTC offset that is + // stored in the pump state. The entry.timestamp.toInstant() above must + // use this current UTC offset to produce correct results. This is + // particularly important during daylight savings changes. Only *after* + // the Instant timestamps were all created we can proceed and update the + // pump state's UTC offset. + // TBRs are not affected by this, because the TBR info we store in the + // pump state is already stored as an Instant, so it stores the timezone + // offset along with the actual timestamp. + // For the same reason, we *first* update the pump's datetime (if there + // is a deviation from the system datetime) and *then* update the UTC + // offset. The pump is still running with the localtime that is tied + // to the old UTC offset. + + // Check if the system's current datetime and the pump's are at least + // 2 minutes apart. If so, update the pump's current datetime. + // We use a threshold of 2 minutes (= 120 seconds) since (a) the + // pump datetime can only be set with a granularity at the minute + // level (while getting its current datetime returns seconds), and + // (b) setting datetime takes a while because it has to be done + // via the RT mode. Having this threshold avoids too frequent + // pump datetime updates (which, as said, are rather slow). + if (dateTimeDelta.absoluteValue >= 2.toDuration(DurationUnit.MINUTES)) { + logger(LogLevel.INFO) { + "Current system datetime differs from pump's too much, updating pump datetime; " + + "system / pump datetime (UTC): $currentSystemDateTime / $currentPumpDateTime; " + + "datetime delta: $dateTimeDelta" + } + // Shift the pump's new datetime into the future, using a simple + // heuristic that estimates how long it will take updatePumpDateTime() + // to complete the adjustment. If the difference between the pump's + // current datetime and the current system datetime is rather large, + // updatePumpDateTime() can take a significant amount of time to + // complete (in some extreme cases even more than a minute). It is + // possible that by the time the set datetime operation is finished, + // the pump's current datetime is already too far or at least almost + // too far in the past. By estimating the updatePumpDateTime() + // duration and taking it into account, we minimize the chances + // of the pump's new datetime being too old already. + val newPumpDateTimeShift = estimateDateTimeSetDurationFrom(currentPumpDateTime, currentSystemDateTime, currentSystemTimeZone) + updatePumpDateTime( + (currentSystemDateTime + newPumpDateTimeShift).toLocalDateTime(currentSystemTimeZone) + ) + } else { + logger(LogLevel.INFO) { + "Current system datetime is close enough to pump's current datetime, " + + "no pump datetime adjustment needed; " + + "system / pump datetime (UTC): $currentSystemDateTime / $currentPumpDateTime; " + + "datetime delta: $dateTimeDelta" + } + } + + // Check if the pump's current UTC offset matches that of the system. + + if (currentSystemUtcOffset != currentPumpUtcOffset!!) { + logger(LogLevel.INFO) { + "System UTC offset differs from pump's; system timezone: $currentSystemTimeZone; " + + "system UTC offset: $currentSystemUtcOffset; pump state UTC offset: ${currentPumpUtcOffset!!}; " + + "updating pump state" + } + pumpStateStore.setCurrentUtcOffset(bluetoothDevice.address, currentSystemUtcOffset) + currentPumpUtcOffset = currentSystemUtcOffset + } + } + + private suspend fun fetchHistoryDelta(): List { + pumpIO.switchMode(PumpIO.Mode.COMMAND) + return pumpIO.getCMDHistoryDelta() + } + + private suspend fun getBasalProfile(): BasalProfile = executeCommand( + pumpMode = PumpIO.Mode.REMOTE_TERMINAL, + isIdempotent = true, + description = GettingBasalProfileCommandDesc(), + // Allow this since getBasalProfile can be called by connect() during the pump checks. + allowExecutionWhileChecking = true + ) { + getBasalProfileReporter.reset(Unit) + + getBasalProfileReporter.setCurrentProgressStage(RTCommandProgressStage.GettingBasalProfile(0)) + + try { + val basalProfileFactors = MutableList(NUM_COMBO_BASAL_PROFILE_FACTORS) { -1 } + + navigateToRTScreen(rtNavigationContext, ParsedScreen.BasalRateFactorSettingScreen::class, pumpSuspended) + + var numObservedScreens = 0 + var numRetrievedFactors = 0 + + // Do a long RT MENU button press to quickly navigate + // through all basal profile factors, keeping count on + // all observed screens and all retrieved factors to + // be able to later check if all factors were observed. + longPressRTButtonUntil(rtNavigationContext, RTNavigationButton.MENU) { parsedScreen -> + if (parsedScreen !is ParsedScreen.BasalRateFactorSettingScreen) { + logger(LogLevel.ERROR) { "Got a non-profile screen ($parsedScreen)" } + throw UnexpectedRTScreenException( + ParsedScreen.BasalRateFactorSettingScreen::class, + parsedScreen::class + ) + } + + numObservedScreens++ + + val factorIndexOnScreen = parsedScreen.beginTime.hour + + // numUnits null means the basal profile factor + // is currently not shown due to blinking. + if (parsedScreen.numUnits == null) + return@longPressRTButtonUntil LongPressRTButtonsCommand.ContinuePressingButton + + // If the factor in the profile is >= 0, + // it means it was already read earlier. + if (basalProfileFactors[factorIndexOnScreen] >= 0) + return@longPressRTButtonUntil LongPressRTButtonsCommand.ContinuePressingButton + + val factor = parsedScreen.numUnits + basalProfileFactors[factorIndexOnScreen] = factor + logger(LogLevel.DEBUG) { "Got basal profile factor #$factorIndexOnScreen : $factor" } + + getBasalProfileReporter.setCurrentProgressStage( + RTCommandProgressStage.GettingBasalProfile(numRetrievedFactors) + ) + + numRetrievedFactors++ + + return@longPressRTButtonUntil if (numObservedScreens >= NUM_COMBO_BASAL_PROFILE_FACTORS) + LongPressRTButtonsCommand.ReleaseButton + else + LongPressRTButtonsCommand.ContinuePressingButton + } + + // Failsafe in the unlikely case that the longPressRTButtonUntil() + // call above skipped over some basal profile factors. In such + // a case, numRetrievedFactors will be less than 24 (the value of + // NUM_COMBO_BASAL_PROFILE_FACTORS). + // The corresponding items in the basalProfile int list will be set to + // -1, since those items will have been skipped as well. Therefore, + // for each negative item, revisit the corresponding screen. + if (numRetrievedFactors < NUM_COMBO_BASAL_PROFILE_FACTORS) { + for (index in basalProfileFactors.indices) { + // We are only interested in those entries that have been + // skipped. Those entries are set to their initial value (-1). + if (basalProfileFactors[index] >= 0) + continue + + logger(LogLevel.DEBUG) { "Re-reading missing basal profile factor $index" } + + shortPressRTButtonsUntil(rtNavigationContext) { parsedScreen -> + if (parsedScreen !is ParsedScreen.BasalRateFactorSettingScreen) { + logger(LogLevel.ERROR) { "Got a non-profile screen ($parsedScreen)" } + throw UnexpectedRTScreenException( + ParsedScreen.BasalRateFactorSettingScreen::class, + parsedScreen::class + ) + } + + val factorIndexOnScreen = parsedScreen.beginTime.hour + + if (factorIndexOnScreen == index) { + // Do nothing if the factor is currently not + // shown due to blinking. Eventually, the + // factor becomes visible again. + val factor = parsedScreen.numUnits ?: return@shortPressRTButtonsUntil ShortPressRTButtonsCommand.DoNothing + + basalProfileFactors[index] = factor + logger(LogLevel.DEBUG) { "Got basal profile factor #$index : $factor" } + + // We got the factor, so we can stop short-pressing the RT button. + return@shortPressRTButtonsUntil ShortPressRTButtonsCommand.Stop + } else { + // This is not the correct basal profile factor, so keep + // navigating through them to find the correct factor. + return@shortPressRTButtonsUntil ShortPressRTButtonsCommand.PressButton( + RTNavigationButton.MENU) + } + } + + getBasalProfileReporter.setCurrentProgressStage( + RTCommandProgressStage.GettingBasalProfile(numRetrievedFactors) + ) + numRetrievedFactors++ + } + } + + // All factors retrieved. Press CHECK once to get back to the total + // basal rate screen, and then CHECK again to return to the main menu. + + rtNavigationContext.shortPressButton(RTNavigationButton.CHECK) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.BasalRateTotalScreen::class) + + rtNavigationContext.shortPressButton(RTNavigationButton.CHECK) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.MainScreen::class) + + getBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Finished) + + return@executeCommand BasalProfile(basalProfileFactors) + } catch (e: CancellationException) { + getBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Cancelled) + throw e + } catch (e: Exception) { + getBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Error(e)) + throw e + } + } + + // NOTE: The reportPumpSuspendedTbr() and reportStartedTbr() functions + // do NOT call setCurrentTbr() themselves. They just report TBR changes, + // and do nothing else. + + // If the pump is suspended, there is no insulin delivery. Model this + // as a 0% TBR that started just now and lasts for 60 minutes. + private fun reportPumpSuspendedTbr() = + reportStartedTbr(Tbr(timestamp = Clock.System.now(), percentage = 0, durationInMinutes = 60, Tbr.Type.COMBO_STOPPED)) + + private fun reportStartedTbr(tbr: Tbr) { + // If a TBR is already ongoing, it will be aborted. We have to + // take this into account here, and report the old TBR as ended. + reportOngoingTbrAsStopped() + + pumpStateStore.setCurrentTbrState(bluetoothDevice.address, CurrentTbrState.TbrStarted(tbr)) + onEvent(Event.TbrStarted(tbr)) + _currentTbrFlow.value = tbr + } + + private fun reportOngoingTbrAsStopped() { + val currentTbrState = pumpStateStore.getCurrentTbrState(bluetoothDevice.address) + if (currentTbrState is CurrentTbrState.TbrStarted) { + // In a TemporaryBasalRateEnded event, the timestamp indicates the + // time when a TBR ended. The ongoing TBR we know of may already have + // expired. If so, we have to be careful to use the correct timestamp. + // Compare the duration between the TBR start and now with the duration + // as indicated by the TBR's durationInMinutes field. If the duration + // between TBR start and now is longer than durationInMinutes, then + // the TBR ended a while ago, and the timestamp has to reflect that, + // meaning that using the current time as the timestamp would be wrong + // in this case. If however the duration between TBR start and now + // is _shorter_ than durationInMinutes, it means that we stopped TBR + // before its planned end, so using the current time as timestamp + // is the correct approach then. + val now = Clock.System.now() + val tbr = currentTbrState.tbr + val tbrDuration = tbr.durationInMinutes.toDuration(DurationUnit.MINUTES) + val (endTbrTimestamp, newDurationInMinutes) = if ((now - tbr.timestamp) > tbrDuration) + Pair(tbr.timestamp + tbrDuration, tbr.durationInMinutes) + else + Pair(now, (now - tbr.timestamp).inWholeMinutes.toInt()) + + onEvent(Event.TbrEnded(Tbr( + timestamp = tbr.timestamp, + percentage = tbr.percentage, + durationInMinutes = newDurationInMinutes, + tbr.type + ), endTbrTimestamp)) + _currentTbrFlow.value = null + } + } + + private suspend fun updatePumpDateTime( + newPumpLocalDateTime: LocalDateTime + ) = executeCommand( + pumpMode = PumpIO.Mode.REMOTE_TERMINAL, + isIdempotent = true, + description = UpdatingPumpDateTimeCommandDesc(newPumpLocalDateTime), + allowExecutionWhileSuspended = true, + allowExecutionWhileChecking = true + ) { + setDateTimeProgressReporter.reset(Unit) + + // In the time and date setting screens, only long-press the RT button if the + // quantity differs by more than 5. 5 and less means <= 5 button short button + // presses, which are faster than a long- and short-press sequence. + val longRTButtonPressPredicate = fun(targetQuantity: Int, quantityOnScreen: Int): Boolean = + ((targetQuantity - quantityOnScreen).absoluteValue) >= PUMP_DATETIME_UPDATE_LONG_RT_BUTTON_PRESS_THRESHOLD + + try { + setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeHour) + + // Navigate from our current location to the first screen - the hour screen. + navigateToRTScreen(rtNavigationContext, ParsedScreen.TimeAndDateSettingsHourScreen::class, pumpSuspended) + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = newPumpLocalDateTime.hour, + longRTButtonPressPredicate = longRTButtonPressPredicate, + cyclicQuantityRange = 24, + incrementSteps = arrayOf(Pair(0, 1)) + ) { parsedScreen -> + (parsedScreen as ParsedScreen.TimeAndDateSettingsHourScreen).hour + } + + // From here on, we just need to press MENU to move to the next datetime screen. + + setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeMinute) + rtNavigationContext.shortPressButton(RTNavigationButton.MENU) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TimeAndDateSettingsMinuteScreen::class) + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = newPumpLocalDateTime.minute, + longRTButtonPressPredicate = longRTButtonPressPredicate, + cyclicQuantityRange = 60, + incrementSteps = arrayOf(Pair(0, 1)) + ) { parsedScreen -> + (parsedScreen as ParsedScreen.TimeAndDateSettingsMinuteScreen).minute + } + + setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeYear) + rtNavigationContext.shortPressButton(RTNavigationButton.MENU) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TimeAndDateSettingsYearScreen::class) + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = newPumpLocalDateTime.year, + longRTButtonPressPredicate = longRTButtonPressPredicate, + incrementSteps = arrayOf(Pair(0, 1)) + ) { parsedScreen -> + (parsedScreen as ParsedScreen.TimeAndDateSettingsYearScreen).year + } + + setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeMonth) + rtNavigationContext.shortPressButton(RTNavigationButton.MENU) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TimeAndDateSettingsMonthScreen::class) + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = newPumpLocalDateTime.monthNumber, + longRTButtonPressPredicate = longRTButtonPressPredicate, + cyclicQuantityRange = 12, + incrementSteps = arrayOf(Pair(0, 1)) + ) { parsedScreen -> + (parsedScreen as ParsedScreen.TimeAndDateSettingsMonthScreen).month + } + + // TODO: Set the cyclicQuantityRange for days. This is a little tricky + // though, since the exact number of days varies not only between + // months, but also between years (see February 29th). See if something + // in kotlinx.datetime can be used for this. Avoid self-made calendar + // logic here; such logic is easily error prone. + setDateTimeProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingDateTimeDay) + rtNavigationContext.shortPressButton(RTNavigationButton.MENU) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TimeAndDateSettingsDayScreen::class) + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = newPumpLocalDateTime.dayOfMonth, + longRTButtonPressPredicate = longRTButtonPressPredicate, + incrementSteps = arrayOf(Pair(0, 1)) + ) { parsedScreen -> + (parsedScreen as ParsedScreen.TimeAndDateSettingsDayScreen).day + } + + // Everything configured. Press CHECK to confirm the new datetime. + rtNavigationContext.shortPressButton(RTNavigationButton.CHECK) + + setDateTimeProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished) + } catch (e: CancellationException) { + setDateTimeProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled) + throw e + } catch (e: Exception) { + setDateTimeProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e)) + throw e + } + } + + private fun estimateDateTimeSetDurationFrom( + currentPumpDateTime: Instant, + currentSystemDateTime: Instant, + timezone: TimeZone + ): Duration { + // In here, we use a simple heuristic to estimate how long it will take the updatePumpDateTime() + // function to adjust the pump's local datetime to match the system datetime. It looks at the + // individual differences in minute/hour/day/month/year values, evaluates them, and decides + // based on that how long it should take updatePumpDateTime() to bring the current quantity + // to the target quantity. This is a conservative estimate that does not factor in blinked-out + // screens and is always shorter than the actual duration updatePumpDateTime() takes to finish + // the adjustment. This is important, otherwise we may end up setting the pump's local datetime + // to a timestamp that lies in the future. This is OK if it is only maybe at most a few seconds + // in the future, but anything beyond that could cause problems, because then, the timestamps + // of following bolus deliveries etc. would all be shifted into the future, and potentially + // confuse AAPS and its IOB calculations (since the bolus dosage would not be factored in + // right away if the bolus timestamp is shifted into the future). + + val currentLocalPumpDateTime = currentPumpDateTime.toLocalDateTime(timezone) + val currentLocalSystemDateTime = currentSystemDateTime.toLocalDateTime(timezone) + + fun calcCyclicDistance(begin: Int, end: Int, range: Int): Int { + val simpleDistance = (end - begin).absoluteValue + return if (simpleDistance <= (range / 2)) simpleDistance else (range - simpleDistance) + } + fun calcNormalDistance(begin: Int, end: Int): Int { + return (end - begin).absoluteValue + } + fun calcLongRTButtonPressObservationPeriod(distance: Int): Duration { + // Check if the distance is large enough to trigger a long RT button press. If so, + // factor in 2 seconds. This is the time it takes after the long button + // press completes to wait until the quantity post-button press can be read. + // After the long RT button press is over, the code observes the RT screen + // updates for about 2 seconds before it extracts the new current quantity + // from the RT screen. + // If however no long RT button press is expected, then no such waiting / + // observation period will happen, so return 0 then instead. + return (if (distance >= PUMP_DATETIME_UPDATE_LONG_RT_BUTTON_PRESS_THRESHOLD) 2 else 0).toDuration(DurationUnit.SECONDS) + } + + // When calculating the "distances" (= how many button in/decrements it takes to reach the + // target quantity), also factor in cyclic behavior if there is one. + val hourDistance = calcCyclicDistance(currentLocalPumpDateTime.hour, currentLocalSystemDateTime.hour, 24) + val minuteDistance = calcCyclicDistance(currentLocalPumpDateTime.minute, currentLocalSystemDateTime.minute, 60) + val yearDistance = calcNormalDistance(currentLocalPumpDateTime.year, currentLocalSystemDateTime.year) + val monthDistance = calcCyclicDistance(currentLocalPumpDateTime.monthNumber, currentLocalSystemDateTime.monthNumber, 12) + val dayDistance = calcNormalDistance(currentLocalPumpDateTime.dayOfMonth, currentLocalSystemDateTime.dayOfMonth) + val totalDistance = hourDistance + minuteDistance + yearDistance + monthDistance + dayDistance + + val estimatedDuration = + // 2 seconds to account for navigation to the time and date settings screens. + 2.toDuration(DurationUnit.SECONDS) + + // 1 second per quantity to factor in the waiting period while reading each initial quantity. + // We handle 5 quantities (hour/minute/year/month/day), so we factor in 5*1 seconds. + 5.toDuration(DurationUnit.SECONDS) + + // Factor in the individual factor changes (1 increment/decrement takes ~300 ms to finish). + (totalDistance * 300).toDuration(DurationUnit.MILLISECONDS) + + // if a long RT button press happens, there's a waiting period after the button press stopped. + // IMPORTANT: This is evaluated for each distance individually instead of evaluating + // totalDistance once. That's because whether to do long RT button press is decided per-quantity + // and not once for all quantities. + calcLongRTButtonPressObservationPeriod(hourDistance) + + calcLongRTButtonPressObservationPeriod(minuteDistance) + + calcLongRTButtonPressObservationPeriod(yearDistance) + + calcLongRTButtonPressObservationPeriod(monthDistance) + + calcLongRTButtonPressObservationPeriod(dayDistance) + + logger(LogLevel.DEBUG) { + "Current local pump / system datetime: $currentLocalPumpDateTime / $currentLocalSystemDateTime " + + "; estimated duration: $estimatedDuration" + } + + return estimatedDuration + } + + private suspend fun setCurrentTbr( + percentage: Int, + durationInMinutes: Int + ) { + setTbrProgressReporter.reset(Unit) + + setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRPercentage(0)) + + try { + var initialQuantityDistance: Int? = null + + // Only long-press the RT button if we have to increase / decrease + // the TBR percentage by more than 50. Otherwise, short-pressing + // is sufficient; adjusting by 50 requires only 5 button presses. + val longRTButtonPressPercentagePredicate = fun(targetQuantity: Int, quantityOnScreen: Int): Boolean = + ((targetQuantity - quantityOnScreen).absoluteValue) >= 50 + + // First, set the TBR percentage. + navigateToRTScreen(rtNavigationContext, ParsedScreen.TemporaryBasalRatePercentageScreen::class, pumpSuspended) + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = percentage, + longRTButtonPressPredicate = longRTButtonPressPercentagePredicate, + // TBR duration is in/decremented in 10-minute steps + incrementSteps = arrayOf(Pair(0, 10)) + ) { + val currentPercentage = (it as ParsedScreen.TemporaryBasalRatePercentageScreen).percentage + + // Calculate the progress out of the "distance" from the + // current percentage to the target percentage. As we adjust + // the quantity, that "distance" shrinks. When it is 0, we + // consider the adjustment to be complete, or in other words, + // the settingProgress to be at 100. + // In the corner case where the current percentage displayed + // on screen is already the target percentage, we just set + // settingProgress straight to 100. + if (currentPercentage != null) { + if (initialQuantityDistance == null) { + initialQuantityDistance = currentPercentage - percentage + } else { + val settingProgress = if (initialQuantityDistance == 0) { + 100 + } else { + val currentQuantityDistance = currentPercentage - percentage + (100 - currentQuantityDistance * 100 / initialQuantityDistance!!).coerceIn(0, 100) + } + setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRPercentage(settingProgress)) + } + } + + currentPercentage + } + + // If the percentage is 100%, we are done (and navigating to + // the duration screen is not possible). Otherwise, continue. + if (percentage != 100) { + initialQuantityDistance = null + + // Only long-press the RT button if we have to increase / decrease + // the TBR duration by more than 60 minutes. Otherwise, short-pressing + // is sufficient; adjusting by 60 minutes requires only 4 button presses. + val longRTButtonPressDurationPredicate = fun(targetQuantity: Int, quantityOnScreen: Int): Boolean = + ((targetQuantity - quantityOnScreen).absoluteValue) >= 60 + + setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRDuration(0)) + + // Now move to the duration screen by pressing MENU. + rtNavigationContext.shortPressButton(RTNavigationButton.MENU) + waitUntilScreenAppears(rtNavigationContext, ParsedScreen.TemporaryBasalRateDurationScreen::class) + + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = durationInMinutes, + longRTButtonPressPredicate = longRTButtonPressDurationPredicate, + // TBR percentage is in/decremented in 15 percentage point steps + incrementSteps = arrayOf(Pair(0, 15)) + ) { + val currentDuration = (it as ParsedScreen.TemporaryBasalRateDurationScreen).durationInMinutes + + // Do the settingProgress calculation just like before when setting the percentage. + if (currentDuration != null) { + if (initialQuantityDistance == null) { + initialQuantityDistance = currentDuration - durationInMinutes + } else { + val settingProgress = if (initialQuantityDistance == 0) { + 100 + } else { + val currentQuantityDistance = currentDuration - durationInMinutes + (100 - currentQuantityDistance * 100 / initialQuantityDistance!!).coerceIn(0, 100) + } + setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRDuration(settingProgress)) + } + } + + currentDuration + } + } + + setTbrProgressReporter.setCurrentProgressStage(RTCommandProgressStage.SettingTBRDuration(100)) + + // TBR set. Press CHECK to confirm it and exit back to the main menu. + rtNavigationContext.shortPressButton(RTNavigationButton.CHECK) + + setTbrProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished) + } catch (e: CancellationException) { + setTbrProgressReporter.setCurrentProgressStage(BasicProgressStage.Cancelled) + throw e + } catch (e: Exception) { + setTbrProgressReporter.setCurrentProgressStage(BasicProgressStage.Error(e)) + throw e + } + } + + private suspend fun updateStatusByReadingMainAndQuickinfoScreens(switchStatesIfNecessary: Boolean) { + val mainScreen = navigateToRTScreen(rtNavigationContext, ParsedScreen.MainScreen::class, pumpSuspended) + + val mainScreenContent = when (mainScreen) { + is ParsedScreen.MainScreen -> mainScreen.content + else -> throw NoUsableRTScreenException() + } + + val quickinfoScreen = navigateToRTScreen(rtNavigationContext, ParsedScreen.QuickinfoMainScreen::class, pumpSuspended) + + val quickinfo = when (quickinfoScreen) { + is ParsedScreen.QuickinfoMainScreen -> { + // After parsing the quickinfo screen, exit back to the main screen by pressing BACK. + rtNavigationContext.shortPressButton(RTNavigationButton.BACK) + quickinfoScreen.quickinfo + } + else -> throw NoUsableRTScreenException() + } + + _statusFlow.value = when (mainScreenContent) { + is MainScreenContent.Normal -> { + pumpSuspended = false + Status( + availableUnitsInReservoir = quickinfo.availableUnits, + activeBasalProfileNumber = mainScreenContent.activeBasalProfileNumber, + currentBasalRateFactor = mainScreenContent.currentBasalRateFactor, + tbrOngoing = false, + remainingTbrDurationInMinutes = 0, + tbrPercentage = 100, + reservoirState = quickinfo.reservoirState, + batteryState = mainScreenContent.batteryState + ) + } + + is MainScreenContent.Stopped -> { + pumpSuspended = true + Status( + availableUnitsInReservoir = quickinfo.availableUnits, + activeBasalProfileNumber = 0, + // The stopped screen does not show any basal rate + // factor. Set this to 0 to let the caller know + // that the current factor is unknown. + currentBasalRateFactor = 0, + tbrOngoing = false, + remainingTbrDurationInMinutes = 0, + tbrPercentage = 0, + reservoirState = quickinfo.reservoirState, + batteryState = mainScreenContent.batteryState + ) + } + + is MainScreenContent.Tbr -> { + pumpSuspended = false + Status( + availableUnitsInReservoir = quickinfo.availableUnits, + activeBasalProfileNumber = mainScreenContent.activeBasalProfileNumber, + // The main screen shows the basal rate factor with the TBR + // percentage applied (= multiplied) to it. Undo this operation + // to get the original basal rate factor. We can't undo a + // multiplication by zero though, so just set the rate to 0 + // if TBR is 0%. + currentBasalRateFactor = if (mainScreenContent.tbrPercentage != 0) + mainScreenContent.currentBasalRateFactor * 100 / mainScreenContent.tbrPercentage + else + 0, + tbrOngoing = true, + remainingTbrDurationInMinutes = mainScreenContent.remainingTbrDurationInMinutes, + tbrPercentage = mainScreenContent.tbrPercentage, + reservoirState = quickinfo.reservoirState, + batteryState = mainScreenContent.batteryState + ) + } + + is MainScreenContent.ExtendedOrMultiwaveBolus -> + throw ExtendedOrMultiwaveBolusActiveException(mainScreenContent) + } + + if (switchStatesIfNecessary) { + // See if the pump was suspended and now isn't anymore, or vice versa. + // In these cases, we must update the current state. + if (pumpSuspended && (stateFlow.value == State.ReadyForCommands)) + setState(State.Suspended) + else if (!pumpSuspended && (stateFlow.value == State.Suspended)) + setState(State.ReadyForCommands) + } + } +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/PumpManager.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/PumpManager.kt new file mode 100644 index 0000000000..15ec8117c9 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/PumpManager.kt @@ -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() + + /** + * 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() + + 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? + ) { + // 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" } + } +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/RTNavigation.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/RTNavigation.kt new file mode 100644 index 0000000000..dc512234fa --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/main/RTNavigation.kt @@ -0,0 +1,1150 @@ +package info.nightscout.comboctl.main + +import info.nightscout.comboctl.base.ApplicationLayer +import info.nightscout.comboctl.base.ComboException +import info.nightscout.comboctl.base.Graph +import info.nightscout.comboctl.base.LogLevel +import info.nightscout.comboctl.base.Logger +import info.nightscout.comboctl.base.PumpIO +import info.nightscout.comboctl.base.connectBidirectionally +import info.nightscout.comboctl.base.connectDirectionally +import info.nightscout.comboctl.base.findShortestPath +import info.nightscout.comboctl.base.getElapsedTimeInMs +import info.nightscout.comboctl.parser.ParsedScreen +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlin.math.absoluteValue +import kotlin.math.min +import kotlin.reflect.KClassifier + +private val logger = Logger.get("RTNavigation") + +private const val WAIT_PERIOD_DURING_LONG_RT_BUTTON_PRESS_IN_MS = 110L +private const val MAX_NUM_SAME_QUANTITY_OBSERVATIONS = 10 + +/** + * RT navigation buttons. + * + * These are essentially the [ApplicationLayer.RTButton] values, but + * also include combined button presses for navigating back (which + * requires pressing both MENU and UP buttons at the same time). + */ +enum class RTNavigationButton(val rtButtonCodes: List) { + UP(listOf(ApplicationLayer.RTButton.UP)), + DOWN(listOf(ApplicationLayer.RTButton.DOWN)), + MENU(listOf(ApplicationLayer.RTButton.MENU)), + CHECK(listOf(ApplicationLayer.RTButton.CHECK)), + + BACK(listOf(ApplicationLayer.RTButton.MENU, ApplicationLayer.RTButton.UP)), + UP_DOWN(listOf(ApplicationLayer.RTButton.UP, ApplicationLayer.RTButton.DOWN)) +} + +internal data class RTEdgeValue(val button: RTNavigationButton, val edgeValidityCondition: EdgeValidityCondition = EdgeValidityCondition.ALWAYS) { + enum class EdgeValidityCondition { + ONLY_IF_COMBO_STOPPED, + ONLY_IF_COMBO_RUNNING, + ALWAYS + } + + // Exclude edgeValidityCondition from comparisons. This is mainly + // done to make it easier to test the RT navigation code. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as RTEdgeValue + + if (button != other.button) return false + + return true + } + + override fun hashCode(): Int { + return button.hashCode() + } +} + +// Directed cyclic graph for navigating between RT screens. The edge +// values indicate what button to press to reach the edge's target node +// (= target screen). The button may have to be pressed more than once +// until the target screen appears if other screens are in between. +internal val rtNavigationGraph = Graph().apply { + // Set up graph nodes for each ParsedScreen, to be able + // to connect them below. + val mainNode = node(ParsedScreen.MainScreen::class) + val quickinfoNode = node(ParsedScreen.QuickinfoMainScreen::class) + val tbrMenuNode = node(ParsedScreen.TemporaryBasalRateMenuScreen::class) + val tbrPercentageNode = node(ParsedScreen.TemporaryBasalRatePercentageScreen::class) + val tbrDurationNode = node(ParsedScreen.TemporaryBasalRateDurationScreen::class) + val myDataMenuNode = node(ParsedScreen.MyDataMenuScreen::class) + val myDataBolusDataMenuNode = node(ParsedScreen.MyDataBolusDataScreen::class) + val myDataErrorDataMenuNode = node(ParsedScreen.MyDataErrorDataScreen::class) + val myDataDailyTotalsMenuNode = node(ParsedScreen.MyDataDailyTotalsScreen::class) + val myDataTbrDataMenuNode = node(ParsedScreen.MyDataTbrDataScreen::class) + val basalRate1MenuNode = node(ParsedScreen.BasalRate1ProgrammingMenuScreen::class) + val basalRateTotalNode = node(ParsedScreen.BasalRateTotalScreen::class) + val basalRateFactorSettingNode = node(ParsedScreen.BasalRateFactorSettingScreen::class) + val timeDateSettingsMenuNode = node(ParsedScreen.TimeAndDateSettingsMenuScreen::class) + val timeDateSettingsHourNode = node(ParsedScreen.TimeAndDateSettingsHourScreen::class) + val timeDateSettingsMinuteNode = node(ParsedScreen.TimeAndDateSettingsMinuteScreen::class) + val timeDateSettingsYearNode = node(ParsedScreen.TimeAndDateSettingsYearScreen::class) + val timeDateSettingsMonthNode = node(ParsedScreen.TimeAndDateSettingsMonthScreen::class) + val timeDateSettingsDayNode = node(ParsedScreen.TimeAndDateSettingsDayScreen::class) + + // Below, nodes are connected. Connections are edges in the graph. + + // Main screen and quickinfo. + connectBidirectionally(RTEdgeValue(RTNavigationButton.CHECK), RTEdgeValue(RTNavigationButton.BACK), mainNode, quickinfoNode) + + connectBidirectionally( + RTEdgeValue(RTNavigationButton.MENU), RTEdgeValue(RTNavigationButton.BACK), + myDataMenuNode, basalRate1MenuNode + ) + + // Connection between main menu and time and date settings menu. Note that there + // is only this one connection to the time and date settings menu, even though it + // is actually possible to reach that menu from for example the basal rate 1 + // programming one by pressing MENU several times. That's because depending on + // the Combo's configuration, significantly more menus may actually lie between + // basal rate 1 and time and date settings, causing the navigation to take + // significantly longer. Also, in pretty much all cases, any access to the time + // and date settings menu starts from the main menu, so it makes sense to establish + // only one connection between the main menu and the time and date settings menu. + connectBidirectionally( + RTEdgeValue(RTNavigationButton.BACK), RTEdgeValue(RTNavigationButton.MENU), + mainNode, + timeDateSettingsMenuNode + ) + + // Connections to the TBR menu do not always exist - if the Combo + // is stopped, the TBR menu is disabled, so create separate connections + // for it and mark them as being invalid if the Combo is stopped to + // prevent the RTNavigation code from traversing them if the Combo + // is currently in the stopped state. + + // These are the TBR menu connections. In the running state, the + // TBR menu is then directly reachable from the main menu and is + // placed in between the main and the My Data menu. + connectBidirectionally( + RTEdgeValue(RTNavigationButton.MENU, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING), + RTEdgeValue(RTNavigationButton.BACK, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING), + mainNode, tbrMenuNode + ) + connectBidirectionally( + RTEdgeValue(RTNavigationButton.MENU, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING), + RTEdgeValue(RTNavigationButton.BACK, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING), + tbrMenuNode, myDataMenuNode + ) + + // In the stopped state, the My Data menu can directly be reached from the + // main mode, since the TBR menu that is in between is turned off. + connectBidirectionally( + RTEdgeValue(RTNavigationButton.MENU, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_STOPPED), + RTEdgeValue(RTNavigationButton.BACK, RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_STOPPED), + mainNode, myDataMenuNode + ) + + // These are the connections between TBR screens. A specialty of these + // screens is that transitioning between the percentage and duration + // screens is done with the MENU screen in both directions + // (percentage->duration and duration->percentage). The TBR menu screen + // can be reached from both of these screens by pressing BACK. But the + // duration screen cannot be reached directly from the TBR menu screen, + // which is why there's a direct edge from the duration to the menu + // screen but not one in the other direction. + connectBidirectionally(RTEdgeValue(RTNavigationButton.CHECK), RTEdgeValue(RTNavigationButton.BACK), tbrMenuNode, tbrPercentageNode) + connectBidirectionally(RTEdgeValue(RTNavigationButton.MENU), RTEdgeValue(RTNavigationButton.MENU), tbrPercentageNode, tbrDurationNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), tbrDurationNode, tbrMenuNode) + + // The basal rate programming screens. Going to the basal rate factors requires + // two transitions (basal rate 1 -> basal rate total -> basal rate factor). + // Going back requires one, but directly goes back to basal rate 1. + connectBidirectionally(RTEdgeValue(RTNavigationButton.CHECK), RTEdgeValue(RTNavigationButton.BACK), basalRate1MenuNode, basalRateTotalNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.MENU), basalRateTotalNode, basalRateFactorSettingNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), basalRateFactorSettingNode, basalRate1MenuNode) + + // Connections between myData screens. Navigation through these screens + // is rather straightforward. Pressing CHECK when at the my data menu + // transitions to the bolus data screen. Pressing MENU then transitions + // through the various myData screens. The order is: bolus data, error + // data, daily totals, TBR data. Pressing MENU when at the TBR data + // screen cycles back to the bolus data screen. Pressing BACK in any + // of these screens transitions back to the my data menu screen. + connectDirectionally(RTEdgeValue(RTNavigationButton.CHECK), myDataMenuNode, myDataBolusDataMenuNode) + connectDirectionally( + RTEdgeValue(RTNavigationButton.MENU), + myDataBolusDataMenuNode, myDataErrorDataMenuNode, myDataDailyTotalsMenuNode, myDataTbrDataMenuNode + ) + connectDirectionally(RTEdgeValue(RTNavigationButton.MENU), myDataTbrDataMenuNode, myDataBolusDataMenuNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), myDataBolusDataMenuNode, myDataMenuNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), myDataErrorDataMenuNode, myDataMenuNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), myDataDailyTotalsMenuNode, myDataMenuNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), myDataTbrDataMenuNode, myDataMenuNode) + + // Time and date settings screen. These work just like the my data screens. + // That is: Navigating between the "inner" time and date screens works + // by pressing MENU, and when pressing MENU at the last of these screens, + // navigation transitions back to the first of these screens. Pressing + // BACK transitions back to the time and date settings menu screen. + connectDirectionally(RTEdgeValue(RTNavigationButton.CHECK), timeDateSettingsMenuNode, timeDateSettingsHourNode) + connectDirectionally( + RTEdgeValue(RTNavigationButton.MENU), + timeDateSettingsHourNode, timeDateSettingsMinuteNode, timeDateSettingsYearNode, + timeDateSettingsMonthNode, timeDateSettingsDayNode + ) + connectDirectionally(RTEdgeValue(RTNavigationButton.MENU), timeDateSettingsDayNode, timeDateSettingsHourNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsHourNode, timeDateSettingsMenuNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsMinuteNode, timeDateSettingsMenuNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsYearNode, timeDateSettingsMenuNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsMonthNode, timeDateSettingsMenuNode) + connectDirectionally(RTEdgeValue(RTNavigationButton.BACK), timeDateSettingsDayNode, timeDateSettingsMenuNode) +} + +/** + * Base class for exceptions thrown when navigating through remote terminal (RT) screens. + * + * @param message The detail message. + */ +open class RTNavigationException(message: String) : ComboException(message) + +/** + * Exception thrown when the RT navigation could not find a screen of the searched type. + * + * @property targetScreenType Type of the screen that was searched. + */ +class CouldNotFindRTScreenException(val targetScreenType: KClassifier) : + RTNavigationException("Could not find RT screen $targetScreenType") + +/** + * Exception thrown when the RT navigation encountered an unexpected screen type. + * + * @property expectedScreenType Type of the screen that was expected. + * @property encounteredScreenType Type of the screen that was encountered. + */ +class UnexpectedRTScreenException( + val expectedScreenType: KClassifier, + val encounteredScreenType: KClassifier +) : RTNavigationException("Unexpected RT screen; expected $expectedScreenType, encountered $encounteredScreenType") + +/** + * Exception thrown when in spite of repeatedly trying to exit to the main screen, no recognizable RT screen is found. + * + * This is different from [NoUsableRTScreenException] in that the code tried to get out + * of whatever unrecognized part of the RT menu and failed because it kept seeing unfamiliar + * screens, while that other exception is about not getting a specific RT screen. + */ +class CouldNotRecognizeAnyRTScreenException : RTNavigationException("Could not recognize any RT screen") + +/** + * Exception thrown when a function needed a specific screen type but could not get it. + * + * Typically, this happens because a display frame could not be parsed + * (= the screen is [ParsedScreen.UnrecognizedScreen]). + */ +class NoUsableRTScreenException : RTNavigationException("No usable RT screen available") + +/** + * Exception thrown when [adjustQuantityOnScreen] attempts to adjust the shown quantity but hits an unexpected limit. + * + * For example, if the quantity shall be set to 500, but after incrementing it, it + * suddenly stops incrementing at 200, this exception is thrown to alert the user + * about this unexpected behavior. + * + * @param targetQuantity Quantity that was supposed to be reached. + * @param hitLimitAt The quantity at which point adjustments stopped changing the quantity. + */ +class QuantityNotChangingException( + val targetQuantity: Int, + val hitLimitAt: Int +) : RTNavigationException("Attempted to adjust quantity to target value $targetQuantity, but hit limit at $hitLimitAt") + +/** + * Remote terminal (RT) navigation context. + * + * This provides the necessary functionality for functions that navigate through RT screens + * like [cycleToRTScreen]. These functions analyze [ParsedScreen] instances contained + * in incoming [ParsedDisplayFrame] ones, and apply changes & transitions with the provided + * abstract button actions. + * + * The button press functions are almost exactly like the ones from [PumpIO]. The only + * difference is how buttons are specified - the underlying PumpIO functions get the + * [RTNavigationButton.rtButtonCodes] value of their "button" arguments, and not the + * "button" argument directly. + */ +interface RTNavigationContext { + /** + * Maximum number of times functions like [cycleToRTScreen] can cycle through screens. + * + * This is a safeguard to prevent infinite loops in case these functions like [cycleToRTScreen] + * fail to find the screen they are looking for. This is a quantity that defines how + * often these functions can transition to other screens without getting to the screen + * they are looking for. Past that amount, they throw [CouldNotFindRTScreenException]. + * + * This is always >= 1, and typically a value like 20. + */ + val maxNumCycleAttempts: Int + + fun resetDuplicate() + + suspend fun getParsedDisplayFrame(filterDuplicates: Boolean, processAlertScreens: Boolean = true): ParsedDisplayFrame? + + suspend fun startLongButtonPress(button: RTNavigationButton, keepGoing: (suspend () -> Boolean)? = null) + suspend fun stopLongButtonPress() + suspend fun waitForLongButtonPressToFinish() + suspend fun shortPressButton(button: RTNavigationButton) +} + +/** + * [PumpIO] based implementation of [RTNavigationContext]. + * + * This uses a [PumpIO] instance to pass button actions to, and provides a stream + * of [ParsedDisplayFrame] instances. It is the implementation suited for + * production use. [maxNumCycleAttempts] is set to 20 by default. + */ +class RTNavigationContextProduction( + private val pumpIO: PumpIO, + private val parsedDisplayFrameStream: ParsedDisplayFrameStream, + override val maxNumCycleAttempts: Int = 20 +) : RTNavigationContext { + init { + require(maxNumCycleAttempts > 0) + } + + override fun resetDuplicate() = parsedDisplayFrameStream.resetDuplicate() + + override suspend fun getParsedDisplayFrame(filterDuplicates: Boolean, processAlertScreens: Boolean) = + parsedDisplayFrameStream.getParsedDisplayFrame(filterDuplicates, processAlertScreens) + + override suspend fun startLongButtonPress(button: RTNavigationButton, keepGoing: (suspend () -> Boolean)?) = + pumpIO.startLongRTButtonPress(button.rtButtonCodes, keepGoing) + + override suspend fun stopLongButtonPress() = pumpIO.stopLongRTButtonPress() + + override suspend fun waitForLongButtonPressToFinish() = pumpIO.waitForLongRTButtonPressToFinish() + + override suspend fun shortPressButton(button: RTNavigationButton) = pumpIO.sendShortRTButtonPress(button.rtButtonCodes) +} + +sealed class ShortPressRTButtonsCommand { + object DoNothing : ShortPressRTButtonsCommand() + object Stop : ShortPressRTButtonsCommand() + data class PressButton(val button: RTNavigationButton) : ShortPressRTButtonsCommand() +} + +sealed class LongPressRTButtonsCommand { + object ContinuePressingButton : LongPressRTButtonsCommand() + object ReleaseButton : LongPressRTButtonsCommand() +} + +/** + * Holds down a specific button until the specified screen check callback returns true. + * + * This is useful for performing an ongoing activity based on the screen contents. + * [adjustQuantityOnScreen] uses this internally for adjusting a quantity on screen. + * [button] is kept pressed until [checkScreen] returns [LongPressRTButtonsCommand.ReleaseButton], + * at which point that RT button is released. + * + * NOTE: The RT button may actually be released a little past the time [checkScreen] + * indicates that the RT button is to be released. This is due to limitations in how + * the RT screen UX works. It is recommended to add checks after running the long RT + * button press if the state of the RT screen afterwards is important. For example, + * when adjusting a quantity on the RT screen, check afterwards the quantity once it + * stops in/decrementing and correct it with short RT button presses if needed. + * + * @param rtNavigationContext Context to use for the long RT button press. + * @param button Button to long-press. + * @param checkScreen Callback that returns whether to continue + * long-pressing the button or releasing it. + * @return The last observed [ParsedScreen]. + * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen. + */ +suspend fun longPressRTButtonUntil( + rtNavigationContext: RTNavigationContext, + button: RTNavigationButton, + checkScreen: (parsedScreen: ParsedScreen) -> LongPressRTButtonsCommand +): ParsedScreen { + lateinit var lastParsedScreen: ParsedScreen + + logger(LogLevel.DEBUG) { "Long-pressing RT button $button" } + + rtNavigationContext.resetDuplicate() + + var thrownDuringButtonPress: Throwable? = null + + rtNavigationContext.startLongButtonPress(button) { + // Suspend the block until either we get a new parsed display frame + // or WAIT_PERIOD_DURING_LONG_RT_BUTTON_PRESS_IN_MS milliseconds + // pass. In the latter case, we instruct startLongButtonPress() + // to just continue pressing the button. In the former case, + // we analyze the screen and act according to the result. + // We use withTimeout(), because sometimes, the Combo may not + // immediately return a frame just because we are pressing the + // button. If we just wait for the next frame, we can then end + // up waiting forever. + + val timestampBeforeDisplayFrameRetrieval = getElapsedTimeInMs() + + // Receive the parsedDisplayFrame, and if none is received or if + // the timeout expires (parsedDisplayFrame gets set to null in + // both cases), keep pressing the button. + val parsedDisplayFrame = try { + withTimeout( + timeMillis = WAIT_PERIOD_DURING_LONG_RT_BUTTON_PRESS_IN_MS + ) { + rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) + } + } catch (e: TimeoutCancellationException) { + // Timeout expired, and we got no new frame. Stop waiting + // for one and continue long-pressing the button. We might + // be on a screen that does not update on its own. + null + } catch (t: Throwable) { + // An exception that's not TimeoutCancellationException + // was thrown. Catch it, store it to rethrow it later, + // and end the long button press. + thrownDuringButtonPress = t + return@startLongButtonPress false + } ?: return@startLongButtonPress true + + // It is possible that we got a parsed display frame very quickly. + // Wait a while in such a case to avoid overrunning the Combo + // with button press packets. In such a case, the Combo's ring + // buffer would overflow, and an error would occur. (This seems + // to be a phenomenon that is separate to the packet overflow + // that is documented in TransportLayer.IO.sendInternal().) + val elapsedTime = getElapsedTimeInMs() - timestampBeforeDisplayFrameRetrieval + if (elapsedTime < WAIT_PERIOD_DURING_LONG_RT_BUTTON_PRESS_IN_MS) { + val waitingPeriodInMs = WAIT_PERIOD_DURING_LONG_RT_BUTTON_PRESS_IN_MS - elapsedTime + logger(LogLevel.VERBOSE) { "Waiting $waitingPeriodInMs milliseconds before continuing button long-press" } + delay(timeMillis = waitingPeriodInMs) + } + + // At this point, we got a non-null parsedDisplayFrame that we can + // analyze. The analysis is done by checkScreen. If an exception + // is thrown by that callback, catch and store it, stop pressing + // the button, and exit. The code further below re-throws the + // stored exception. + val parsedScreen = parsedDisplayFrame.parsedScreen + val predicateResult = try { + checkScreen(parsedScreen) + } catch (t: Throwable) { + thrownDuringButtonPress = t + return@startLongButtonPress false + } + + // Proceed according to the result of checkScreen. + val releaseButton = (predicateResult == LongPressRTButtonsCommand.ReleaseButton) + logger(LogLevel.VERBOSE) { + "Observed parsed screen $parsedScreen while long-pressing RT button; predicate result = $predicateResult" + } + if (releaseButton) { + // Record the screen we just saw so we can return it. + lastParsedScreen = parsedScreen + return@startLongButtonPress false + } + else + return@startLongButtonPress true + } + + // The block that is passed to startLongButtonPress() runs in a + // background coroutine. We wait here for that coroutine to finish. + rtNavigationContext.waitForLongButtonPressToFinish() + + // Rethrow previously caught exception (if there was any). + thrownDuringButtonPress?.let { + logger(LogLevel.INFO) { "Rethrowing Throwable caught during long RT button press: $it" } + throw it + } + + logger(LogLevel.DEBUG) { "Long-pressing RT button $button stopped" } + + return lastParsedScreen +} + +/** + * Short-presses a button until the specified screen check callback returns true. + * + * This is the short-press counterpart to [longPressRTButtonUntil]. For each observed + * [ParsedScreen], it invokes the specified [processScreen] callback. That callback + * then returns a command, telling this function what to do next. If that command is + * [ShortPressRTButtonsCommand.PressButton], this function short-presses the button + * specified in that sealed subclass, and then waits for the next [ParsedScreen]. + * If the command is [ShortPressRTButtonsCommand.Stop], this function finishes. + * If the command is [ShortPressRTButtonsCommand.DoNothing], this function skips + * the current [ParsedScreen]. The last command is useful for example when the + * screen contents are blinking. By returning DoNothing, the callback effectively + * causes this function to wait until another screen (hopefully without the blinking) + * arrives and can be processed by that callback. + * + * @param rtNavigationContext Context to use for the short RT button press. + * @param processScreen Callback that returns the command this function shall execute next. + * @return The last observed [ParsedScreen]. + * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen. + */ +suspend fun shortPressRTButtonsUntil( + rtNavigationContext: RTNavigationContext, + processScreen: (parsedScreen: ParsedScreen) -> ShortPressRTButtonsCommand +): ParsedScreen { + logger(LogLevel.DEBUG) { "Repeatedly short-pressing RT button according to callback commands" } + + rtNavigationContext.resetDuplicate() + + while (true) { + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + + logger(LogLevel.VERBOSE) { "Got new screen $parsedScreen" } + + val command = processScreen(parsedScreen) + logger(LogLevel.VERBOSE) { "Short-press RT button callback returned $command" } + + when (command) { + ShortPressRTButtonsCommand.DoNothing -> Unit + ShortPressRTButtonsCommand.Stop -> return parsedScreen + is ShortPressRTButtonsCommand.PressButton -> rtNavigationContext.shortPressButton(command.button) + } + } +} + +/** + * Repeatedly presses the [button] until a screen of the required [targetScreenType] appears. + * + * @param rtNavigationContext Context for navigating to the target screen. + * @param button Button to press for cycling to the target screen. + * @param targetScreenType Type of the target screen. + * @return The last observed [ParsedScreen]. + * @throws CouldNotFindRTScreenException if the screen was not found even + * after this function moved [RTNavigationContext.maxNumCycleAttempts] + * times from screen to screen. + * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen. + */ +suspend fun cycleToRTScreen( + rtNavigationContext: RTNavigationContext, + button: RTNavigationButton, + targetScreenType: KClassifier +): ParsedScreen { + logger(LogLevel.DEBUG) { "Running shortPressRTButtonsUntil() until screen of type $targetScreenType is observed" } + var cycleCount = 0 + return shortPressRTButtonsUntil(rtNavigationContext) { parsedScreen -> + if (cycleCount >= rtNavigationContext.maxNumCycleAttempts) + throw CouldNotFindRTScreenException(targetScreenType) + + when (parsedScreen::class) { + targetScreenType -> { + logger(LogLevel.DEBUG) { "Target screen of type $targetScreenType reached; cycleCount = $cycleCount" } + ShortPressRTButtonsCommand.Stop + } + else -> { + cycleCount++ + logger(LogLevel.VERBOSE) { "Did not yet reach target screen type; cycleCount increased to $cycleCount" } + ShortPressRTButtonsCommand.PressButton(button) + } + } + } +} + +/** + * Keeps watching out for incoming screens until one of the desired type is observed. + * + * @param rtNavigationContext Context for observing incoming screens. + * @param targetScreenType Type of the target screen. + * @return The last observed [ParsedScreen], which is the screen this + * function was waiting for. + * @throws CouldNotFindRTScreenException if the screen was not seen even after + * this function observed [RTNavigationContext.maxNumCycleAttempts] + * screens coming in. + * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen. + */ +suspend fun waitUntilScreenAppears( + rtNavigationContext: RTNavigationContext, + targetScreenType: KClassifier +): ParsedScreen { + logger(LogLevel.DEBUG) { "Observing incoming parsed screens and waiting for screen of type $targetScreenType to appear" } + var cycleCount = 0 + + rtNavigationContext.resetDuplicate() + + while (true) { + if (cycleCount >= rtNavigationContext.maxNumCycleAttempts) + throw CouldNotFindRTScreenException(targetScreenType) + + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + + if (parsedScreen::class == targetScreenType) { + logger(LogLevel.DEBUG) { "Target screen of type $targetScreenType appeared; cycleCount = $cycleCount" } + return parsedScreen + } else { + logger(LogLevel.VERBOSE) { "Target screen type did not appear yet; cycleCount increased to $cycleCount" } + cycleCount++ + } + } +} + +/** + * Adjusts a quantity that is shown currently on screen, using the specified in/decrement buttons. + * + * Internally, this first uses a long RT button press to quickly change the quantity + * to be as close as possible to the [targetQuantity]. Then, with short RT button + * presses, any leftover differences between the currently shown quantity and + * [targetQuantity] is corrected. + * + * The current quantity is extracted from the current [ParsedScreen] with the + * [getQuantity] callback. That callback returns null if the quantity currently + * is not available (typically happens because the screen is blinking). This + * will not cause an error; instead, this function will just wait until the + * callback returns a non-null value. + * + * Some quantities may be cyclic in nature. For example, a minute value has a valid range + * of 0-59, but if the current value is 55, and the target value is 3, it is faster to press + * the [incrementButton] until the value wraps around from 59 to 0 and then keeps increasing + * to 3. The alternative would be to press the [decrementButton] 52 times, which is slower. + * This requires a non-null [cyclicQuantityRange] value. If that argument is null, this + * function will not do such a cyclic logic. + * + * Sometimes, it may be beneficial to _not_ long-press the RT button. This is typically + * the case if the quantity on screen is already very close to [targetQuantity]. In such + * a case, [longRTButtonPressPredicate] becomes useful. A long RT button press only takes + * place if [longRTButtonPressPredicate] returns true. Its arguments are [targetQuantity] + * and the quantity on screen. The default predicate always returns true. + * + * [incrementSteps] specifies how the quantity on screen would increment/decrement if the + * [incrementButton] or [decrementButton] were pressed. This is an array of Pair integers. + * For each pair, the first integer in the Pair specifies the threshold, the second integer + * is the step size. Example value: `arrayOf(Pair(0, 10), Pair(100, 50), Pair(1000, 100))`. + * This means: Values in the 0-100 range are in/decremented by a step size of 10. Values + * in the 100-1000 range are incremented by a step size of 50. Values at or above 1000 + * are incremented by a step size of 100. + * + * NOTE: If [cyclicQuantityRange] is not null, [incrementSteps] must have exactly one item. + * + * @param rtNavigationContext Context to use for adjusting the quantity. + * @param targetQuantity Quantity to set the on-screen quantity to. + * @param incrementButton What RT button to press for incrementing the on-screen quantity. + * @param decrementButton What RT button to press for decrementing the on-screen quantity. + * @param cyclicQuantityRange The cyclic quantity range, or null if no such range exists. + * @param longRTButtonPressPredicate Quantity delta predicate for enabling RT button presses. + * @param incrementSteps The step sizes and thresholds the pump uses for in/decrementing. + * Must contain at least one item. + * @param getQuantity Callback for extracting the on-screen quantity. + * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen. + */ +suspend fun adjustQuantityOnScreen( + rtNavigationContext: RTNavigationContext, + targetQuantity: Int, + incrementButton: RTNavigationButton = RTNavigationButton.UP, + decrementButton: RTNavigationButton = RTNavigationButton.DOWN, + cyclicQuantityRange: Int? = null, + longRTButtonPressPredicate: (targetQuantity: Int, quantityOnScreen: Int) -> Boolean = { _, _ -> true }, + incrementSteps: Array>, + getQuantity: (parsedScreen: ParsedScreen) -> Int? +) { + require(incrementSteps.isNotEmpty()) { "There must be at least one incrementSteps item" } + require((cyclicQuantityRange == null) || (incrementSteps.size == 1)) { + "If cyclicQuantityRange is not null, incrementSteps must contain " + + "exactly one item; actually contains ${incrementSteps.size}" + } + + fun checkIfNeedsToIncrement(currentQuantity: Int): Boolean { + return if (cyclicQuantityRange != null) { + val distance = (targetQuantity - currentQuantity) + if (distance.absoluteValue <= (cyclicQuantityRange / 2)) + (currentQuantity < targetQuantity) + else + (currentQuantity > targetQuantity) + } else + (currentQuantity < targetQuantity) + } + + logger(LogLevel.DEBUG) { + "Adjusting quantity on RT screen; targetQuantity = $targetQuantity; " + + "increment / decrement buttons = $incrementButton / $decrementButton; " + + "cyclicQuantityRange = $cyclicQuantityRange" + } + + var previouslySeenQuantity: Int? = null + var seenSameQuantityCount = 0 + + fun checkIfQuantityUnexpectedlyNotChanging(currentQuantity: Int): Boolean { + // If the quantity stops changing, and is not the target quantity, + // something is wrong. Keep observing until MAX_NUM_SAME_QUANTITY_OBSERVATIONS + // observations are made where the quantity remained unchanged, and then + // report the situation as bogus (= return false). + if ((previouslySeenQuantity == null) || (previouslySeenQuantity != currentQuantity)) { + previouslySeenQuantity = currentQuantity + seenSameQuantityCount = 0 + return false + } + + seenSameQuantityCount++ + + return (seenSameQuantityCount >= MAX_NUM_SAME_QUANTITY_OBSERVATIONS) + } + + val initialQuantity: Int + rtNavigationContext.resetDuplicate() + + // Get the quantity that is initially shown on screen. + // This is necessary to (a) check if anything needs to + // be done at all and (b) decide what button to long-press + // in the code block below. + while (true) { + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + val quantity = getQuantity(parsedScreen) + if (quantity != null) { + initialQuantity = quantity + break + } + } + + logger(LogLevel.DEBUG) { "Initial observed quantity: $initialQuantity" } + + if (initialQuantity == targetQuantity) { + logger(LogLevel.DEBUG) { "Initial quantity is already the target quantity; nothing to do" } + return + } + + val currentQuantity: Int + + if (longRTButtonPressPredicate(targetQuantity, initialQuantity)) { + val needToIncrement = checkIfNeedsToIncrement(initialQuantity) + logger(LogLevel.DEBUG) { + "First phase; long-pressing RT button to " + + "${if (needToIncrement) "increment" else "decrement"} quantity" + } + + // First phase: Adjust quantity with a long RT button press. + // This is (much) faster than using short RT button presses, + // but can overshoot, especially since the Combo increases the + // increment/decrement steps over time. + longPressRTButtonUntil( + rtNavigationContext, + if (needToIncrement) incrementButton else decrementButton + ) { parsedScreen -> + val currentQuantity = getQuantity(parsedScreen) + logger(LogLevel.VERBOSE) { "Current quantity in first phase: $currentQuantity; need to increment: $needToIncrement" } + if (currentQuantity == null) { + LongPressRTButtonsCommand.ContinuePressingButton + } else { + if (currentQuantity != targetQuantity) { + if (checkIfQuantityUnexpectedlyNotChanging(currentQuantity)) { + logger(LogLevel.ERROR) { "Quantity unexpectedly not changing" } + throw QuantityNotChangingException(targetQuantity = targetQuantity, hitLimitAt = currentQuantity) + } + } + + // If we are incrementing, and did not yet reach the + // quantity, then we expect checkIfNeedsToIncrement() + // to indicate that further incrementing is required. + // The opposite is also true: If we are decrementing, + // and didn't reach the quantity yet, we expect + // checkIfNeedsToIncrement() to return false. We use + // this to determine if we need to continue long-pressing + // the RT button. If the current quantity is at the + // target, we don't have to anymore. And if we overshot, + // checkIfNeedsToIncrement() will return the opposite + // of what we expect. In both of these cases, keepPressing + // will be set to false, indicating that the long RT + // button press needs to stop. + val keepPressing = + if (currentQuantity == targetQuantity) + false + else if (needToIncrement) + checkIfNeedsToIncrement(currentQuantity) + else + !checkIfNeedsToIncrement(currentQuantity) + + if (keepPressing) + LongPressRTButtonsCommand.ContinuePressingButton + else + LongPressRTButtonsCommand.ReleaseButton + } + } + + var lastQuantity: Int? = null + var sameQuantityObservedCount = 0 + rtNavigationContext.resetDuplicate() + + // Observe the screens until we see a screen whose quantity + // is the same as the previous screen's, and we see the quantity + // not changing 3 times. This "debouncing" is necessary because + // the Combo may be somewhat behind with the display frames it + // sends to the client. This means that even after the + // longPressRTButtonUntil() call above finished, the Combo may + // still send several send updates, and the on-screen quantity + // may still be in/decremented. We need to wait until that + // in/decrementing is over before we can do any corrections + // with short RT button presses. And to be sure that it is + // over, we have to observe the frames for a short while. + // This also implies that long-pressing the RT button should + // really only be done if the quantity on screen differs + // significantly from the target quantity, otherwise the + // waiting / observation period for this "debouncing" will + // overshadow any speed gains the long-press may yield. + // See the longRTButtonPressPredicate documentation. + while (true) { + // Do not filter for duplicates, since a duplicate + // is pretty much what we are waiting for. + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = false) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + val currentQuantity = getQuantity(parsedScreen) + + logger(LogLevel.DEBUG) { + "Observed quantity after long-pressing RT button: " + + "last / current quantity: $lastQuantity / $currentQuantity" + } + + if (currentQuantity != null) { + if (currentQuantity == lastQuantity) { + sameQuantityObservedCount++ + if (sameQuantityObservedCount >= 3) + break + } else { + lastQuantity = currentQuantity + sameQuantityObservedCount = 0 + } + } + } + + if (lastQuantity == targetQuantity) { + logger(LogLevel.DEBUG) { "Last seen quantity $lastQuantity is the target quantity; adjustment finished" } + return + } + + logger(LogLevel.DEBUG) { + "Second phase: last seen quantity $lastQuantity is not the target quantity; " + + "short-pressing RT button(s) to finetune it" + } + + currentQuantity = lastQuantity!! + } else { + while (true) { + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + val quantity = getQuantity(parsedScreen) + if (quantity != null) { + currentQuantity = quantity + break + } + } + } + + // If the on-screen quantity is not the target quantity, we may + // have overshot, or the in/decrement factor may have been increased + // over time by the Combo. Perform short RT button presses to nudge + // the quantity until it reaches the target value. Alternatively, + // the long RT button press was skipped by request. In that case, + // we must adjust with short RT button presses. + val (numNeededShortRTButtonPresses: Int, shortRTButtonToPress) = computeShortRTButtonPress( + currentQuantity = currentQuantity, + targetQuantity = targetQuantity, + cyclicQuantityRange = cyclicQuantityRange, + incrementSteps = incrementSteps, + incrementButton = incrementButton, + decrementButton = decrementButton + ) + if (numNeededShortRTButtonPresses != 0) { + logger(LogLevel.DEBUG) { + "Need to short-press the $shortRTButtonToPress " + + "RT button $numNeededShortRTButtonPresses time(s)" + } + repeat(numNeededShortRTButtonPresses) { + // Get display frames. We don't actually do anything with the frame + // (other than check for a blinked-out screen); this here is done + // just to avoid missing alert screens while we short-press the button. + // If an alert screen appears, getParsedDisplayFrame() throws an + // AlertScreenException, the caller handles the exception, and if the + // operation that was being performed before the alert screen appeared + // can be retried, the caller can attempt to do so. + while (true) { + val displayFrame = rtNavigationContext.getParsedDisplayFrame(processAlertScreens = true, filterDuplicates = true) + if ((displayFrame != null) && displayFrame.parsedScreen.isBlinkedOut) { + logger(LogLevel.DEBUG) { "Screen is blinked out (contents: ${displayFrame.parsedScreen}); skipping" } + continue + } + break + } + rtNavigationContext.shortPressButton(shortRTButtonToPress) + } + } else { + logger(LogLevel.DEBUG) { + "Quantity on screen is already equal to target quantity; no need to press any button" + } + } +} + +/** + * Navigates from the current screen to the screen of the given type. + * + * This performs a navigation by pressing the appropriate RT buttons to + * transition between screens until the target screen is reached. This uses + * an internal navigation tree to compute the shortest path from the current + * to the target screen. If no path to the target screen can be found, + * [CouldNotFindRTScreenException] is thrown. + * + * Depending on the value of [isComboStopped], the pathfinding algorithm may + * take different routes, since some screens are only enabled when the pump + * is running/stopped. + * + * @param rtNavigationContext Context to use for navigating. + * @param targetScreenType Type of the target screen. + * @param isComboStopped True if the Combo is currently stopped. + * @return The target screen. + * @throws CouldNotFindRTScreenException if the screen was not seen even after + * this function observed [RTNavigationContext.maxNumCycleAttempts] + * screens coming in, or if no path from the current screen to + * [targetScreenType] could be found. + * @throws CouldNotRecognizeAnyRTScreenException if the RT menu is at an + * unknown, unrecognized screen at the moment, and in spite of repeatedly + * pressing the BACK button to exit back to the main menu, the code + * kept seeing unrecognized screens. + * @throws info.nightscout.comboctl.parser.AlertScreenException if alert screens are seen. + */ +suspend fun navigateToRTScreen( + rtNavigationContext: RTNavigationContext, + targetScreenType: KClassifier, + isComboStopped: Boolean +): ParsedScreen { + logger(LogLevel.DEBUG) { "About to navigate to RT screen of type $targetScreenType" } + + // Get the current screen to know the starting point. If it is an + // unrecognized screen, press BACK until we are at the main screen. + var numAttemptsToRecognizeScreen = 0 + lateinit var currentParsedScreen: ParsedScreen + + rtNavigationContext.resetDuplicate() + + while (true) { + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + + if (parsedScreen is ParsedScreen.UnrecognizedScreen) { + numAttemptsToRecognizeScreen++ + if (numAttemptsToRecognizeScreen >= rtNavigationContext.maxNumCycleAttempts) + throw CouldNotRecognizeAnyRTScreenException() + rtNavigationContext.shortPressButton(RTNavigationButton.BACK) + } else { + currentParsedScreen = parsedScreen + break + } + } + + if (currentParsedScreen::class == targetScreenType) { + logger(LogLevel.DEBUG) { "Already at target; exiting" } + return currentParsedScreen + } + + logger(LogLevel.DEBUG) { "Navigation starts at screen of type ${currentParsedScreen::class} and ends at screen of type $targetScreenType" } + + // Figure out the shortest path. + var path = try { + findShortestRtPath(currentParsedScreen::class, targetScreenType, isComboStopped) + } catch (e: IllegalArgumentException) { + // Happens when currentParsedScreen::class or targetScreenType are not found in the navigation tree. + null + } + + if (path?.isEmpty() ?: false) + return currentParsedScreen + + if (path == null) { + // If we reach this place, then the currentParsedScreen was recognized by the parser, + // but there is no known path in the rtNavigationGraph to get from there to the target. + // Try exiting by repeatedly pressing BACK. cycleToRTScreen() takes care of that. + // If it fails to find the main screen, it throws a CouldNotFindRTScreenException. + + logger(LogLevel.WARN) { + "We are at screen of type ${currentParsedScreen::class}, which is unknown " + + "to findRTNavigationPath(); exiting back to the main screen" + } + currentParsedScreen = cycleToRTScreen( + rtNavigationContext, + RTNavigationButton.BACK, + ParsedScreen.MainScreen::class + ) + + // Now try again to find a path. We should get a valid path now. We would + // not be here otherwise, since cycleToRTScreen() throws an exception then. + path = try { + findShortestRtPath(currentParsedScreen::class, targetScreenType, isComboStopped) + } catch (e: IllegalArgumentException) { + listOf() + } + + if (path == null) { + // Should not happen due to the cycleToRTScreen() call above. + logger(LogLevel.ERROR) { "Could not find RT navigation path even after navigating back to the main menu" } + throw CouldNotFindRTScreenException(targetScreenType) + } + } + + rtNavigationContext.resetDuplicate() + + // Navigate from the current to the target screen. + var cycleCount = 0 + val pathIt = path.iterator() + var nextPathItem = pathIt.next() + var previousScreenType: KClassifier? = null + while (true) { + if (cycleCount >= rtNavigationContext.maxNumCycleAttempts) + throw CouldNotFindRTScreenException(targetScreenType) + + val parsedDisplayFrame = rtNavigationContext.getParsedDisplayFrame(filterDuplicates = true) ?: continue + val parsedScreen = parsedDisplayFrame.parsedScreen + + // Check if we got the same screen with different content, for example + // when remaining TBR duration is shown on the main screen and the + // duration happens to change during this loop. If this occurs, + // skip the redundant screen. + if ((previousScreenType != null) && (previousScreenType == parsedScreen::class)) { + logger(LogLevel.DEBUG) { "Got a screen of the same type ${parsedScreen::class}; skipping" } + continue + } + previousScreenType = parsedScreen::class + + // A path item's targetNodeValue is the screen type we are trying + // to reach, and the edgeValue is the RT button to press to reach it. + // We stay at the same path item until we reach the screen type that + // is specified by targetNodeValue. When that happens, we move on + // to the next path item. Importantly, we _first_ move on to the next + // item, and _then_ send the short RT button press based on that next + // item, to avoid sending the RT button from the incorrect path item. + // Example: Path item 1 contains target screen type A and RT button + // MENU. Path item 2 contains target screen type B and RT button CHECK. + // On every iteration, we first check if the current screen is of type + // A. If it isn't, we need to press MENU again and check in the next + // iteration again. If it is of type A however, then pressing MENU + // would be incorrect, since we already are at A. Instead, we _first_ + // must move on to the next path item, and _that_ one says to press + // CHECK until type B is reached. + + val nextTargetScreenTypeInPath = nextPathItem.targetNodeValue + + logger(LogLevel.DEBUG) { "We are currently at screen $parsedScreen; next target screen type: $nextTargetScreenTypeInPath" } + + if (parsedScreen::class == nextTargetScreenTypeInPath) { + cycleCount = 0 + if (pathIt.hasNext()) { + nextPathItem = pathIt.next() + logger(LogLevel.DEBUG) { + "Reached screen type $nextTargetScreenTypeInPath in path; " + + "continuing to ${nextPathItem.targetNodeValue}" + } + } else { + // If this is the last path item, it implies + // that we reached our destination. + logger(LogLevel.DEBUG) { "Target screen type $targetScreenType reached" } + return parsedScreen + } + } + + val navButtonToPress = nextPathItem.edgeValue.button + logger(LogLevel.DEBUG) { "Pressing button $navButtonToPress to navigate further" } + rtNavigationContext.shortPressButton(navButtonToPress) + + cycleCount++ + } +} + +internal fun findShortestRtPath(from: KClassifier, to: KClassifier, isComboStopped: Boolean) = + rtNavigationGraph.findShortestPath(from, to) { + when (it.edgeValidityCondition) { + RTEdgeValue.EdgeValidityCondition.ALWAYS -> true + RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_RUNNING -> !isComboStopped + RTEdgeValue.EdgeValidityCondition.ONLY_IF_COMBO_STOPPED -> isComboStopped + } + } + +internal fun computeShortRTButtonPress( + currentQuantity: Int, + targetQuantity: Int, + cyclicQuantityRange: Int?, + incrementSteps: Array>, + incrementButton: RTNavigationButton, + decrementButton: RTNavigationButton +): Pair { + val numNeededShortRTButtonPresses: Int + val shortRTButtonToPress: RTNavigationButton + + // Compute the number of RT button press steps to cover the given distance. + // Use the (x + (d-1)) / d formula (with integer division) to round up the + // result. That's because in case of "half steps", these must be counted as + // one full step. For example, if the current quantity on screen is 21, the + // target quantity is 40, and the step size is 20, then pressing UP will + // cause the Combo to increment the quantity from 21 to 40. A further UP + // button press would then increment from 40 to 60 etc. If we didn't round + // up, the "half-step" would not be counted. In the example above, this + // would compute 0, since (40-21)/20 = 19/20 = 0 (integer division). The + // rounding formula by contrast: (40-21+(20-1))/20 = (19+19)/20 = 38/20 = 1. + fun computeNumSteps(stepSize: Int, distance: Int) = (distance + (stepSize - 1)) / stepSize + + if (currentQuantity == targetQuantity) { + numNeededShortRTButtonPresses = 0 + shortRTButtonToPress = RTNavigationButton.CHECK + } else if (incrementSteps.size == 1) { + val stepSize = incrementSteps[0].second + require(stepSize > 0) + val distance = (targetQuantity - currentQuantity).absoluteValue + if (cyclicQuantityRange != null) { + // With a cyclic quantity, if the absolute distance between + // quantities exceeds half of that range, we have the option + // to change the quantity in the opposite direction which + // requires fewer button presses. For example, if the range + // is 60, and the absolute distance is 40, we'd normally have + // to press a button 40 times to get to the target quantity. + // But since cyclic quantities wrap around, we can instead + // press the opposite button 60-40 = 20 times to also get + // to the target quantity. + if (distance > (cyclicQuantityRange / 2)) { + numNeededShortRTButtonPresses = computeNumSteps(stepSize, cyclicQuantityRange - distance) + shortRTButtonToPress = if (targetQuantity < currentQuantity) incrementButton else decrementButton + } else { + numNeededShortRTButtonPresses = computeNumSteps(stepSize, distance) + shortRTButtonToPress = if (targetQuantity > currentQuantity) incrementButton else decrementButton + } + } else { + numNeededShortRTButtonPresses = computeNumSteps(stepSize, distance) + shortRTButtonToPress = if (targetQuantity > currentQuantity) incrementButton else decrementButton + } + } else { + val (start, end, button) = if (currentQuantity < targetQuantity) + Triple(currentQuantity, targetQuantity, incrementButton) + else + Triple(targetQuantity, currentQuantity, decrementButton) + + shortRTButtonToPress = button + + var currentValue = start + var numPresses = 0 + + for (index in incrementSteps.indices) { + val incrementStep = incrementSteps[index] + val stepSize = incrementStep.second + require(stepSize > 0) + val curRangeStart = incrementStep.first + val curRangeEnd = if (index == incrementSteps.size - 1) + end + else + min(incrementSteps[index + 1].first, end) + + if (currentValue >= curRangeEnd) + continue + + if (currentValue < curRangeStart) + currentValue = curRangeStart + + numPresses += computeNumSteps(stepSize, curRangeEnd - currentValue) + + currentValue = curRangeEnd + + if (currentValue >= end) + break + } + + numNeededShortRTButtonPresses = numPresses + } + + return Pair(numNeededShortRTButtonPresses, shortRTButtonToPress) +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Parser.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Parser.kt new file mode 100644 index 0000000000..26bf417185 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Parser.kt @@ -0,0 +1,1740 @@ +package info.nightscout.comboctl.parser + +import info.nightscout.comboctl.base.ComboException +import info.nightscout.comboctl.base.DisplayFrame +import info.nightscout.comboctl.base.combinedDateTime +import info.nightscout.comboctl.base.timeWithoutDate +import kotlin.reflect.KClassifier +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.atTime + +/***************************************** + *** Screen and screen content classes *** + *****************************************/ + +/* Screens are the final result of parser runs. */ + +/** + * Possible bolus types in the bolus data screen in the "My Data" bolus history. + */ +enum class MyDataBolusType { + STANDARD, + MULTI_WAVE, + EXTENDED +} + +/** + * Possible battery states in the main screens. + */ +enum class BatteryState { + NO_BATTERY, + LOW_BATTERY, + FULL_BATTERY +} + +private fun batteryStateFromSymbol(symbol: SmallSymbol?): BatteryState = + when (symbol) { + SmallSymbol.NO_BATTERY -> BatteryState.NO_BATTERY + SmallSymbol.LOW_BATTERY -> BatteryState.LOW_BATTERY + else -> BatteryState.FULL_BATTERY + } + +/** + * Possible contents of [ParsedScreen.MainScreen]. + */ +sealed class MainScreenContent { + data class Normal( + val currentTime: LocalDateTime, + val activeBasalProfileNumber: Int, + val currentBasalRateFactor: Int, + val batteryState: BatteryState + ) : MainScreenContent() + + data class Stopped( + val currentDateTime: LocalDateTime, + val batteryState: BatteryState + ) : MainScreenContent() + + data class Tbr( + val currentTime: LocalDateTime, + val remainingTbrDurationInMinutes: Int, + val tbrPercentage: Int, + val activeBasalProfileNumber: Int, + val currentBasalRateFactor: Int, + val batteryState: BatteryState + ) : MainScreenContent() + + data class ExtendedOrMultiwaveBolus( + val currentTime: LocalDateTime, + val remainingBolusDurationInMinutes: Int, + val isExtendedBolus: Boolean, + val remainingBolusAmount: Int, + val tbrIsActive: Boolean, + val activeBasalProfileNumber: Int, + val currentBasalRateFactor: Int, + val batteryState: BatteryState + ) : MainScreenContent() +} + +/** + * Possible contents of alert (= warning/error) screens. + */ +sealed class AlertScreenContent { + data class Warning(val code: Int) : AlertScreenContent() + data class Error(val code: Int) : AlertScreenContent() + + /** + * "Content" while the alert symbol & code currently are "blinked out". + */ + object None : AlertScreenContent() +} + +/** + * Exception thrown when an alert screens appear. + * + * @property alertScreenContent The content of the alert screen(s). + */ +class AlertScreenException(val alertScreenContent: AlertScreenContent) : + ComboException("RT alert screen appeared with content: $alertScreenContent") + +/** + * Result of a successful [ToplevelScreenParser] run. + * + * Subclasses which have hour quantities use a 0..23 range for the hour. + * (Even if the screen showed the hour in the 12-hour AM/PM format, it is + * converted to the 24-hour format.) Minute quantities use a 0..59 range. + * + * Insulin units use an integer-encoded-decimal scheme. The last 3 digits of + * the integer make up the 3 most significant fractional digits of a decimal. + * For example, "37.5" is encoded as 37500, "10" as 10000, "0.02" as 20 etc. + * + * If [isBlinkedOut] is true, then the actual contents of the screen are + * currently "blinked out", that is, the screen is blinking, and it is + * at the moment in the phase when the contents aren't shown. + */ +sealed class ParsedScreen(val isBlinkedOut: Boolean = false) { + object UnrecognizedScreen : ParsedScreen() + + data class MainScreen(val content: MainScreenContent) : ParsedScreen() + + object BasalRateProfileSelectionMenuScreen : ParsedScreen() + object BluetoothSettingsMenuScreen : ParsedScreen() + object ExtendedBolusMenuScreen : ParsedScreen() + object MultiwaveBolusMenuScreen : ParsedScreen() + object MenuSettingsMenuScreen : ParsedScreen() + object MyDataMenuScreen : ParsedScreen() + object BasalRate1ProgrammingMenuScreen : ParsedScreen() + object BasalRate2ProgrammingMenuScreen : ParsedScreen() + object BasalRate3ProgrammingMenuScreen : ParsedScreen() + object BasalRate4ProgrammingMenuScreen : ParsedScreen() + object BasalRate5ProgrammingMenuScreen : ParsedScreen() + object PumpSettingsMenuScreen : ParsedScreen() + object ReminderSettingsMenuScreen : ParsedScreen() + object TimeAndDateSettingsMenuScreen : ParsedScreen() + object StandardBolusMenuScreen : ParsedScreen() + object StopPumpMenuScreen : ParsedScreen() + object TemporaryBasalRateMenuScreen : ParsedScreen() + object TherapySettingsMenuScreen : ParsedScreen() + + data class AlertScreen(val content: AlertScreenContent) : + ParsedScreen(isBlinkedOut = (content is AlertScreenContent.None)) + + data class BasalRateTotalScreen(val totalNumUnits: Int, val basalRateNumber: Int) : ParsedScreen() + data class BasalRateFactorSettingScreen( + val beginTime: LocalDateTime, + val endTime: LocalDateTime, + val numUnits: Int?, + val basalRateNumber: Int + ) : ParsedScreen(isBlinkedOut = (numUnits == null)) + + data class TemporaryBasalRatePercentageScreen(val percentage: Int?, val remainingDurationInMinutes: Int?) : + ParsedScreen(isBlinkedOut = (percentage == null)) + data class TemporaryBasalRateDurationScreen(val durationInMinutes: Int?) : + ParsedScreen(isBlinkedOut = (durationInMinutes == null)) + + data class QuickinfoMainScreen(val quickinfo: Quickinfo) : ParsedScreen() + + data class TimeAndDateSettingsHourScreen(val hour: Int?) : + ParsedScreen(isBlinkedOut = (hour == null)) + data class TimeAndDateSettingsMinuteScreen(val minute: Int?) : + ParsedScreen(isBlinkedOut = (minute == null)) + data class TimeAndDateSettingsYearScreen(val year: Int?) : + ParsedScreen(isBlinkedOut = (year == null)) + data class TimeAndDateSettingsMonthScreen(val month: Int?) : + ParsedScreen(isBlinkedOut = (month == null)) + data class TimeAndDateSettingsDayScreen(val day: Int?) : + ParsedScreen(isBlinkedOut = (day == null)) + + /** + * Bolus history entry in the "My Data" section. + */ + data class MyDataBolusDataScreen( + /** + * Index of the currently shown bolus. Valid range is 1 to [totalNumEntries]. + */ + val index: Int, + + /** + * Total number of bolus entries in the pump's history. + */ + val totalNumEntries: Int, + + /** + * Timestamp of when the bolus finished, in localtime. + */ + val timestamp: LocalDateTime, + + /** + * Bolus amount in 0.1 IU units. + */ + val bolusAmount: Int, + + /** + * Type of the bolus (standard / extended / multiwave). + */ + val bolusType: MyDataBolusType, + + /** + * Duration of the bolus in minutes. Set to null if this is a standard bolus. + */ + val durationInMinutes: Int? + ) : ParsedScreen() + + /** + * Alert history entry in the "My Data" section. + * + * (These can be both errors and warnings. The section is called "error data" though.) + */ + data class MyDataErrorDataScreen( + /** + * Index of the currently shown alert. Valid range is 1 to [totalNumEntries]. + */ + val index: Int, + + /** + * Total number of alert entries in the pump's history. + */ + val totalNumEntries: Int, + + /** + * Timestamp of when the alert occurred, in localtime. + */ + val timestamp: LocalDateTime, + + /** + * The alert that occurred. + */ + val alert: AlertScreenContent + ) : ParsedScreen() + + /** + * Total daily dosage (TDD) history entry in the "My Data" section. + */ + data class MyDataDailyTotalsScreen( + /** + * Index of the currently shown TDD entry. Valid range is 1 to [totalNumEntries]. + */ + val index: Int, + + /** + * Total number of TDD entries in the pump's history. + */ + val totalNumEntries: Int, + + /** + * Day for which this entry specifies the TDD amount, in localtime. + */ + val date: LocalDate, + + /** + * TDD amount in 1 IU units. + */ + val totalDailyAmount: Int + ) : ParsedScreen() + + /** + * TBR history entry in the "My Data" section. + */ + data class MyDataTbrDataScreen( + /** + * Index of the currently shown TBR entry. Valid range is 1 to [totalNumEntries]. + */ + val index: Int, + + /** + * Total number of TBR entries in the pump's history. + */ + val totalNumEntries: Int, + + /** + * Timestamp when this TBR ended. + */ + val timestamp: LocalDateTime, + + /** + * TBR percentage, in the 0-500 range. + */ + val percentage: Int, + + /** + * TBR duration in minutes, in the 15-1440 range. + */ + val durationInMinutes: Int + ) : ParsedScreen() +} + +/*************************************************** + *** Fundamental parsers and parser base classes *** + ***************************************************/ + +private fun amPmTo24Hour(hour: Int, amPm: String) = + if ((hour == 12) && (amPm == "AM")) + 0 + else if ((hour != 12) && (amPm == "PM")) + hour + 12 + else if (hour == 24) + 0 + else + hour + +/** + * Context used to keep track of parse state. + */ +class ParseContext( + val tokens: List, + var currentIndex: Int, + var topLeftTime: LocalDateTime? = null +) { + fun hasMoreTokens() = (currentIndex < tokens.size) + + fun nextToken() = tokens[currentIndex] + + fun advance() = currentIndex++ +} + +/** + * Possible parser results. + * + * @property isSuccess true if the result is considered a success. + */ +sealed class ParseResult(val isSuccess: Boolean) { + /** Used when the parser returns a value. This encapsulates said value. */ + class Value(val value: T) : ParseResult(true) + + /** + * Indicates that the parser successfully parsed the expected + * content, but that the content has no values. This is used + * if for a certain symbol is expected to be there, but is not + * actually needed as a value. NoValue results will not be + * included in @Sequence results produced by @SequenceParser.*/ + object NoValue : ParseResult(true) + + /** + * Used by @OptionalParser, and returned if the optional + * content specified in that parser was not found. This + * value is still considered a success since the missing + * content is _optional_. + */ + object Null : ParseResult(true) + + /** + * Returned by @Parser.parse if the @ParseContext + * reaches the end of the list of tokens. + */ + object EndOfTokens : ParseResult(false) + + /** + * Indicates that the parser did not find the expected content. + */ + object Failed : ParseResult(false) + + /** + * Result of a @SequenceParser. + * + * For convenience, this has the @valueAt and @valueAtOrNull + * functions to take a value out of that sequence. Example: + * If element no. 2 in the sequence is an Int, then this + * call gets it: + * + * val value = (parseResult as ParseResult.Sequence).valueAt(2) + * + * @valueAtOrNull works similarly, except that it returns null + * if the parse result at that index is not of type Value<*>. + * + * Note that trying to access an index beyond the valid range + * still produces an [IndexOutOfBoundsException] even when + * using @valueAtOrNull. + */ + class Sequence(val values: List) : ParseResult(true) { + inline fun valueAt(index: Int) = (values[index] as Value<*>).value as T + + inline fun valueAtOrNull(index: Int): T? { + return when (val value = values[index]) { + is Value<*> -> value.value as T + else -> null + } + } + + val size: Int + get() = values.size + } +} + +/** + * Parser base class. + * + * @property returnsValue If true, parsing will produce a value. + * The main parser which doesn't do that is [SingleGlyphParser]. + */ +open class Parser(val returnsValue: Boolean = true) { + fun parse(parseContext: ParseContext): ParseResult { + if (!parseContext.hasMoreTokens()) + return ParseResult.EndOfTokens + + // Preserve the original index in the context. That way, should + // parseImpl fail, the original value of currentIndex prior to + // the parseImpl call can be restored. This is especially important + // when using the FirstSuccessParser, since that one tries multiple + // parsers until one succeds. Restoring the currentIndex is essential + // to give all those parsers the chance to parse the same tokens. + var originalIndex = parseContext.currentIndex + val result = parseImpl(parseContext) + if (!result.isSuccess) + parseContext.currentIndex = originalIndex + + return result + } + + protected open fun parseImpl(parseContext: ParseContext): ParseResult = ParseResult.Failed +} + +/** + * Parses a single specific glyph. + * + * This is used in cases where the screen is expected to have a specific + * glyph at a certain position in the list of tokens. One example would + * be a clock symbol at the top left corner. + * + * Since this looks for a specific glyph, it does not have an actual + * return value. Instead, the information about whether or not parsing + * succeeded already tells everything. That's why this either returns + * [ParseResult.NoValue] (when the specified glyph was found) or + * [ParseResult.Failed] (when the glyph was not found). + */ +class SingleGlyphParser(private val glyph: Glyph) : Parser(returnsValue = false) { + override fun parseImpl(parseContext: ParseContext): ParseResult { + return if (parseContext.nextToken().glyph == glyph) { + parseContext.advance() + ParseResult.NoValue + } else + ParseResult.Failed + } +} + +/** + * Parses a single glyph of a specific type. + * + * Similarly to [SingleGlyphParser], this parses the next token as + * a glyph, returning [ParseResult.Failed] if that token is not a + * glyph or the specified [glyphType]. Unlike [SingleGlyphParser], + * this does have an actual return value, since this "only" specifies + * the glyph _type_, not the actual glyph. + * + * This parses is used for example when a screen can contain a token + * that has a symbol at a specific position, and the symbol indicates + * something (like whether an alert screen contains a warning or an error). + * + * The type is specified via the ::class property. Example: + * + * SingleGlyphTypeParser(Glyph.LargeSymbol::class) + * + * @property glyphType Type of the glyph to expect. + */ +class SingleGlyphTypeParser(private val glyphType: KClassifier) : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val token = parseContext.nextToken() + + return if (token.glyph::class == glyphType) { + parseContext.advance() + ParseResult.Value(token.glyph) + } else + ParseResult.Failed + } +} + +/** + * Parses the available tokens as one string until a non-string glyph is found or the end is reached. + * + * Strings can consist of characters and one of the ".:/()-" symbols. + * This also parses whitespaces and adds them to the produced string. + * Whitespaces are detected by measuring the distance between glyphs. + */ +class StringParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + var parsedString = "" + var lastToken: Token? = null + + while (parseContext.hasMoreTokens()) { + val token = parseContext.nextToken() + val glyph = token.glyph + + // Check if there's a newline or space between the matches. + // If so, we'll insert a whitespace character into the string. + val prependWhitespace = if (lastToken != null) + checkForWhitespaceAndNewline(lastToken, token) + else + false + + val character = when (glyph) { + is Glyph.SmallCharacter -> glyph.character + Glyph.SmallSymbol(SmallSymbol.DOT) -> '.' + Glyph.SmallSymbol(SmallSymbol.SEPARATOR) -> ':' + Glyph.SmallSymbol(SmallSymbol.DIVIDE) -> '/' + Glyph.SmallSymbol(SmallSymbol.BRACKET_LEFT) -> '(' + Glyph.SmallSymbol(SmallSymbol.BRACKET_RIGHT) -> ')' + Glyph.SmallSymbol(SmallSymbol.MINUS) -> '-' + else -> break + } + + if (prependWhitespace) + parsedString += ' ' + + parsedString += character + parseContext.advance() + lastToken = token + } + + return if (parsedString.isEmpty()) + ParseResult.Failed + else + ParseResult.Value(parsedString.uppercase()) + } + + // If true, then there is a whitespace between the matches, + // or the second match is located in a line below the first one. + private fun checkForWhitespaceAndNewline(firstToken: Token, secondToken: Token): Boolean { + val y1 = firstToken.y + val y2 = secondToken.y + + if ((y1 + firstToken.pattern.height + 1) == y2) + return true + + val x1 = firstToken.x + val x2 = secondToken.x + + if ((x1 + firstToken.pattern.width + 1 + 3) < x2) + return true + + return false + } +} + +/** + * Parses the available tokens as one integer until a non-integer glyph is found or the end is reached. + * + * @property parseMode Parse mode. Useful for restricting the valid integer glyphs. + * @property checkForWhitespace If set to true, this checks for whitespaces + * and stops parsing if a whitespace is found. Useful for when there + * are multiple integers visually in a sequence. + */ +class IntegerParser( + private val parseMode: Mode = Mode.ALL_DIGITS, + private val checkForWhitespace: Boolean = false +) : Parser() { + enum class Mode { + ALL_DIGITS, + SMALL_DIGITS_ONLY, + LARGE_DIGITS_ONLY + } + + override fun parseImpl(parseContext: ParseContext): ParseResult { + var integer = 0 + var foundDigits = false + var previousToken: Token? = null + + while (parseContext.hasMoreTokens()) { + val token = parseContext.nextToken() + + if (checkForWhitespace && (previousToken != null)) { + val x1 = previousToken.x + val x2 = token.x + if ((x1 + previousToken.pattern.width + 1 + 3) < x2) + break + } + + when (val glyph = token.glyph) { + is Glyph.SmallDigit -> + when (parseMode) { + Mode.ALL_DIGITS, + Mode.SMALL_DIGITS_ONLY -> integer = integer * 10 + glyph.digit + else -> break + } + + is Glyph.LargeDigit -> + when (parseMode) { + Mode.ALL_DIGITS, + Mode.LARGE_DIGITS_ONLY -> integer = integer * 10 + glyph.digit + else -> break + } + + else -> break + } + + foundDigits = true + + parseContext.advance() + + previousToken = token + } + + return if (foundDigits) + ParseResult.Value(integer) + else + ParseResult.Failed + } +} + +/** + * Parses the available tokens as one decimal until a non-decimal glyph is found or the end is reached. + * + * Decimals are made of digits and the dot symbol. They are encoded in an Int value, + * using a fixed-point decimal representation. The point is shifted by 3 digits to + * the left. If for example the decimal "2.13" is parsed, the resulting Int is set + * to 2130. This is preferred over floating point data types, since the latter can + * be lossy, depending on the parsed value (because some decimals cannot be directly + * represented by IEEE 754 floating point math). + * + * This parser also works if the dot symbol is missing. Then, the parsed number + * is treated as a decimal that only has an integer portion. + */ +class DecimalParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + var integerPart = 0 + var fractionalPart = 0 + var parseFractional = false + var foundDigits = false + + while (parseContext.hasMoreTokens()) { + val token = parseContext.nextToken() + + when (val glyph = token.glyph) { + is Glyph.SmallDigit -> integerPart = integerPart * 10 + glyph.digit + is Glyph.LargeDigit -> integerPart = integerPart * 10 + glyph.digit + + Glyph.SmallSymbol(SmallSymbol.DOT), + Glyph.LargeSymbol(LargeSymbol.DOT) -> { + parseFractional = true + parseContext.advance() + break + } + + else -> break + } + + foundDigits = true + + parseContext.advance() + } + + if (parseFractional) { + var numFractionalDigits = 0 + while (parseContext.hasMoreTokens() && (numFractionalDigits < 3)) { + val token = parseContext.nextToken() + + when (val glyph = token.glyph) { + is Glyph.SmallDigit -> { + fractionalPart = fractionalPart * 10 + glyph.digit + numFractionalDigits++ + } + + is Glyph.LargeDigit -> { + fractionalPart = fractionalPart * 10 + glyph.digit + numFractionalDigits++ + } + + else -> break + } + + foundDigits = true + + parseContext.advance() + } + + for (i in 0 until (3 - numFractionalDigits)) { + fractionalPart *= 10 + } + } + + return if (foundDigits) + ParseResult.Value(integerPart * 1000 + fractionalPart) + else + ParseResult.Failed + } +} + +/** + * Parses the available tokens as a date. + * + * The following date formats are used by the Combo: + * + * DD.MM + * MM/DD + * DD.MM.YY + * MM/DD/YY + * + * The parser handles all of these cases. + * + * The result is a [DateTime] instance with the hour/minute/second fields set to zero. + */ +class DateParser : Parser() { + private val dateRegex = "(\\d\\d)([/\\.])(\\d\\d)([/\\.](\\d\\d))?".toRegex() + private val asciiDigitOffset = '0'.code + + override fun parseImpl(parseContext: ParseContext): ParseResult { + // To be able to handle all date formats without too much + // convoluted (and error prone) parsing code, we use regex. + + var dateString = "" + + while (parseContext.hasMoreTokens()) { + val token = parseContext.nextToken() + val glyph = token.glyph + + dateString += when (glyph) { + // Valid glyphs are converted to characters and added to the string. + is Glyph.SmallDigit -> (glyph.digit + asciiDigitOffset).toChar() + is Glyph.LargeDigit -> (glyph.digit + asciiDigitOffset).toChar() + is Glyph.SmallCharacter -> glyph.character + is Glyph.LargeCharacter -> glyph.character + Glyph.SmallSymbol(SmallSymbol.DIVIDE) -> '/' + Glyph.SmallSymbol(SmallSymbol.DOT) -> '.' + Glyph.LargeSymbol(LargeSymbol.DOT) -> '.' + + // Invalid glyph -> the date string ended, stop scan. + else -> break + } + + parseContext.advance() + } + + val regexResult = dateRegex.find(dateString) ?: return ParseResult.Failed + + val regexGroups = regexResult.groups + val separator = regexGroups[2]!!.value + var year = 0 + var month: Int + var day: Int + + if (separator == ".") { + day = regexGroups[1]!!.value.toInt(radix = 10) + month = regexGroups[3]!!.value.toInt(radix = 10) + } else if (separator == "/") { + day = regexGroups[3]!!.value.toInt(radix = 10) + month = regexGroups[1]!!.value.toInt(radix = 10) + } else + return ParseResult.Failed + + if (regexGroups[5] != null) { + year = regexGroups[5]!!.value.toInt(radix = 10) + 2000 // Combo years always start at the year 2000 + } + + return ParseResult.Value(LocalDate(year = year, monthNumber = month, dayOfMonth = day)) + } +} + +/** + * Parses the available tokens as a time. + * + * The following time formats are used by the Combo: + * + * HH:MM + * HH:MM(AM/PM) + * HH(AM/PM) + * + * Examples: + * 14:00 + * 11:47AM + * 09PM + * + * The parser handles all of these cases. + * + * The result is a [DateTime] instance with the year/month/day fields set to zero. + */ +class TimeParser : Parser() { + private val timeRegex = "(\\d\\d):?(\\d\\d)(AM|PM)?|(\\d\\d)(AM|PM)".toRegex() + private val asciiDigitOffset = '0'.code + + override fun parseImpl(parseContext: ParseContext): ParseResult { + // To be able to handle all time formats without too much + // convoluted (and error prone) parsing code, we use regex. + + var timeString = "" + + while (parseContext.hasMoreTokens()) { + val token = parseContext.nextToken() + val glyph = token.glyph + + timeString += when (glyph) { + // Valid glyphs are converted to characters and added to the string. + is Glyph.SmallDigit -> (glyph.digit + asciiDigitOffset).toChar() + is Glyph.LargeDigit -> (glyph.digit + asciiDigitOffset).toChar() + is Glyph.SmallCharacter -> glyph.character + is Glyph.LargeCharacter -> glyph.character + Glyph.SmallSymbol(SmallSymbol.SEPARATOR) -> ':' + Glyph.LargeSymbol(LargeSymbol.SEPARATOR) -> ':' + + // Invalid glyph -> the time string ended, stop scan. + else -> break + } + + parseContext.advance() + } + + val regexResult = timeRegex.find(timeString) ?: return ParseResult.Failed + + // Analyze the regex find result. + // The Regex result groups are: + // + // #0: The entire string + // #1: Hour from a HH:MM or HH:MM(AM/PM) format + // #2: Minute from a HH:MM or HH:MM(AM/PM) format + // #3: AM/PM specifier from a HH:MM or HH:MM(AM/PM) format + // #4: Hour from a HH(AM/PM) format + // #5: AM/PM specifier from a HH(AM/PM) format + // + // Groups without a found value are set to null. + + val regexGroups = regexResult.groups + var hour: Int + var minute = 0 + + if (regexGroups[1] != null) { + // Possibility 1: This is a time string that matches + // one of these two formats: + // + // HH:MM + // HH:MM(AM/PM) + // + // This means that group #2 must not be null, since it + // contains the minute, and these are required here. + + if (regexGroups[2] == null) + return ParseResult.Failed + + hour = regexGroups[1]!!.value.toInt(radix = 10) + minute = regexGroups[2]!!.value.toInt(radix = 10) + + // Special case that can happen in basal rate factor + // setting screens. The screen that shows the factor + // that starts at 23:00 and ends at 00:00 shows a + // time range from 23:00 to 24:00, and _not to 00:00 + // for some reason. Catch this here, otherwise the + // LocalDateTime class will throw an IllegalArgumentException. + if (hour == 24) + hour = 0 + + // If there is an AM/PM specifier, convert the hour + // to the 24-hour format. + if (regexGroups[3] != null) + hour = amPmTo24Hour(hour, regexGroups[3]!!.value) + } else if (regexGroups[4] != null) { + // Possibility 2: This is a time string that matches + // this format: + // + // HH(AM/PM) + // + // This means that group #5 must not be null, since it + // contains the AM/PM specifier, and it is required here. + + if (regexGroups[5] == null) + return ParseResult.Failed + + hour = amPmTo24Hour( + regexGroups[4]!!.value.toInt(radix = 10), + regexGroups[5]!!.value + ) + } else + return ParseResult.Failed + + return ParseResult.Value(timeWithoutDate(hour = hour, minute = minute)) + } +} + +/****************************************** + *** Intermediate-level utility parsers *** + ******************************************/ + +/** + * Parses tokens using the specified subparser, returning [ParseResult.Null] or [ParseResult.NoValue] if that subparser fails to parse. + * + * This is useful when contents in a screen are not always available. One prominent + * example is a blinking text or number. The subparser does the actual token parsing. + * If that subparser returns [ParseResult.Failed], the OptionalParser returns + * [ParseResult.Null] or [ParseResult.NoValue] instead. (It returns the latter if + * the subparer's returnsValue property is set to true.) This is particularly useful + * with the other utility parsers which themselves return [ParseResult.Failed] if at + * least one of their subparsers fail. Using OptionalParser as one of their subparsers + * prevents that. + * + * @property subParser Parser to parse tokens with. + */ +class OptionalParser(private val subParser: Parser) : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = subParser.parse(parseContext) + + return if (parseResult.isSuccess) { + parseResult + } else { + if (subParser.returnsValue) + ParseResult.Null + else + ParseResult.NoValue + } + } +} + +/** + * Tries to parse tokens with the specified subparsers, stopping when one subparser succeeds or all subparsers failed. + * + * This parser tries its subparsers in the order by which they are stored in the + * [subParsers] list. The first subparser that succeeds is the one whose return + * value is forwarded and used as this parser's return value. + * + * @property subParsers List of parsers to try to parse tokens with. + */ +class FirstSuccessParser(private val subParsers: List) : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + for (subParser in subParsers) { + val parseResult = subParser.parse(parseContext) + + if (parseResult.isSuccess) + return parseResult + } + + return ParseResult.Failed + } +} + +/** + * Parses a sequence of tokens using the specified subparsers. + * + * This is useful for parsing entire screens at once. For example, if + * a screen contains a date, followed by a specific symbol, followed by + * another symbol (not a specific one though) and an optional integer, + * then parsing looks like this: + * + * val parseResult = SequenceParser( + * listOf( + * DateParser(), + * SingleGlyphParser(Glyph.SmallSymbol(Symbol.SMALL_CLOCK)), + * SingleGlyphTypeParser(Glyph.LargeSymbol::class), + * OptionalParser(DecimalParser()) + * ) + * ).parse(parseContext) + * + * Retrieving the values then looks like this: + * + * parseResult as ParseResult.Sequence + * val date = parseResult.valueAt(0) + * val symbolGlyph = parseResult.valueAt(1) + * val optionalInteger = parseResult.valueAtOrNull(2) + * + * Note that the [SingleGlyphParser] is skipped (the indices go from 0 to 2). + * This is because SingleGlyphParser's returnsValue property is set to false. + * The valueAt function skips parsers whose returnsValue property is false. + * Also, with optional parsers, it is recommended to use valueAtOrNull<> + * instead of value<>, since the former returns null if the OptionalParser + * returns [ParseResult.Null], while the latter raises an exception (cast error). + * + * @property subParsers List of parsers to parse tokens with. + * @property allowIncompleteSequences If true, then partial results are + * OK; that is, as soon as one of the subparsers returns + * [ParseResult.EndOfTokens], this function call returns the + * sequence of values parsed so far. If instead set to true, + * [ParseResult.EndOfTokens] is returned in that case. + */ +class SequenceParser(private val subParsers: List, private val allowIncompleteSequences: Boolean = false) : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResults = mutableListOf() + for (subParser in subParsers) { + val parseResult = subParser.parse(parseContext) + + when (parseResult) { + is ParseResult.Value<*> -> parseResults.add(parseResult) + is ParseResult.Sequence -> parseResults.add(parseResult) + ParseResult.NoValue -> Unit + ParseResult.Null -> parseResults.add(ParseResult.Null) + ParseResult.EndOfTokens -> if (allowIncompleteSequences) break else return ParseResult.EndOfTokens + ParseResult.Failed -> return ParseResult.Failed + } + } + + return ParseResult.Sequence(parseResults) + } +} + +/************************************* + *** Top-level screen parser class *** + *************************************/ + +/** + * Top-level parser. + * + * This is the main entrypoint for parsing tokens that were previously + * extracted out of a [DisplayFrame]. Typically, this is not used directly. + * Instead, this is used by [parseDisplayFrame]. + */ +class ToplevelScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext) = FirstSuccessParser( + listOf( + TopLeftClockScreenParser(), + MenuScreenParser(), + TitleStringScreenParser() + ) + ).parse(parseContext) +} + +/** + * Parses a [DisplayFrame] by tokenizing it and then parsing the tokens. + * + * @param displayFrame Display frame to parse. + * @return Parsed screen, or [ParsedScreen.UnrecognizedScreen] if parsing failed. + */ +fun parseDisplayFrame(displayFrame: DisplayFrame): ParsedScreen { + val tokens = findTokens(displayFrame) + val parseContext = ParseContext(tokens, 0) + val parseResult = ToplevelScreenParser().parse(parseContext) + return when (parseResult) { + is ParseResult.Value<*> -> parseResult.value as ParsedScreen + else -> ParsedScreen.UnrecognizedScreen + } +} + +/****************************************** + *** Screen parser intermediate classes *** + ******************************************/ + +class TitleStringScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = StringParser().parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + val titleString = (parseResult as ParseResult.Value<*>).value as String + + // Get an ID for the title. This ID is language independent + // and thus much more useful for identifying the screen here. + val titleId = knownScreenTitles[titleString] + + when (titleId) { + TitleID.QUICK_INFO -> return QuickinfoScreenParser().parse(parseContext) + TitleID.TBR_PERCENTAGE -> return TemporaryBasalRatePercentageScreenParser().parse(parseContext) + TitleID.TBR_DURATION -> return TemporaryBasalRateDurationScreenParser().parse(parseContext) + TitleID.HOUR, + TitleID.MINUTE, + TitleID.YEAR, + TitleID.MONTH, + TitleID.DAY -> return TimeAndDateSettingsScreenParser(titleId).parse(parseContext) + TitleID.BOLUS_DATA -> return MyDataBolusDataScreenParser().parse(parseContext) + TitleID.ERROR_DATA -> return MyDataErrorDataScreenParser().parse(parseContext) + TitleID.DAILY_TOTALS -> return MyDataDailyTotalsScreenParser().parse(parseContext) + TitleID.TBR_DATA -> return MyDataTbrDataScreenParser().parse(parseContext) + else -> Unit + } + + // Further parsers follow that do not actually use + // the title string for identification, and instead + // just skip the title string. To not have to parse + // that string again, these parsers are run here, + // after the string was already parsed. + + return FirstSuccessParser( + listOf( + AlertScreenParser(), + BasalRateTotalScreenParser() + ) + ).parse(parseContext) + } +} + +class MenuScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val lastGlyph = parseContext.tokens.last().glyph + + when (lastGlyph) { + Glyph.LargeSymbol(LargeSymbol.BOLUS) -> return ParseResult.Value(ParsedScreen.StandardBolusMenuScreen) + Glyph.LargeSymbol(LargeSymbol.EXTENDED_BOLUS) -> return ParseResult.Value(ParsedScreen.ExtendedBolusMenuScreen) + Glyph.LargeSymbol(LargeSymbol.MULTIWAVE_BOLUS) -> return ParseResult.Value(ParsedScreen.MultiwaveBolusMenuScreen) + Glyph.LargeSymbol(LargeSymbol.BLUETOOTH_SETTINGS) -> return ParseResult.Value(ParsedScreen.BluetoothSettingsMenuScreen) + Glyph.LargeSymbol(LargeSymbol.MENU_SETTINGS) -> return ParseResult.Value(ParsedScreen.MenuSettingsMenuScreen) + Glyph.LargeSymbol(LargeSymbol.MY_DATA) -> return ParseResult.Value(ParsedScreen.MyDataMenuScreen) + Glyph.LargeSymbol(LargeSymbol.BASAL) -> return ParseResult.Value(ParsedScreen.BasalRateProfileSelectionMenuScreen) + Glyph.LargeSymbol(LargeSymbol.PUMP_SETTINGS) -> return ParseResult.Value(ParsedScreen.PumpSettingsMenuScreen) + Glyph.LargeSymbol(LargeSymbol.REMINDER_SETTINGS) -> return ParseResult.Value(ParsedScreen.ReminderSettingsMenuScreen) + Glyph.LargeSymbol(LargeSymbol.CALENDAR_AND_CLOCK) -> return ParseResult.Value(ParsedScreen.TimeAndDateSettingsMenuScreen) + Glyph.LargeSymbol(LargeSymbol.STOP) -> return ParseResult.Value(ParsedScreen.StopPumpMenuScreen) + Glyph.LargeSymbol(LargeSymbol.TBR) -> return ParseResult.Value(ParsedScreen.TemporaryBasalRateMenuScreen) + Glyph.LargeSymbol(LargeSymbol.THERAPY_SETTINGS) -> return ParseResult.Value(ParsedScreen.TherapySettingsMenuScreen) + else -> Unit + } + + // Special case: If the semi-last glyph is a LARGE_BASAL symbol, + // and the last glyph is a large digit, this may be one of the + // basal rate programming menu screens. + if ((parseContext.tokens.size >= 2) && + (lastGlyph is Glyph.LargeDigit) && + (parseContext.tokens[parseContext.tokens.size - 2].glyph == Glyph.LargeSymbol(LargeSymbol.BASAL))) { + return ParseResult.Value(when (lastGlyph.digit) { + 1 -> ParsedScreen.BasalRate1ProgrammingMenuScreen + 2 -> ParsedScreen.BasalRate2ProgrammingMenuScreen + 3 -> ParsedScreen.BasalRate3ProgrammingMenuScreen + 4 -> ParsedScreen.BasalRate4ProgrammingMenuScreen + 5 -> ParsedScreen.BasalRate5ProgrammingMenuScreen + else -> ParsedScreen.UnrecognizedScreen + }) + } + + return ParseResult.Failed + } +} + +class TopLeftClockScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)), + TimeParser() + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + + parseContext.topLeftTime = parseResult.valueAtOrNull(0) + + return FirstSuccessParser( + listOf( + BasalRateFactorSettingScreenParser(), + NormalMainScreenParser(), + TbrMainScreenParser(), + StoppedMainScreenParser(), + ExtendedAndMultiwaveBolusMainScreenParser() + ) + ).parse(parseContext) + } +} + +/***************************** + *** Screen parser classes *** + *****************************/ + +class AlertScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + OptionalParser(SingleGlyphTypeParser(Glyph.LargeSymbol::class)), // warning/error symbol + OptionalParser(SingleGlyphTypeParser(Glyph.LargeCharacter::class)), // "W" or "E" + OptionalParser(IntegerParser()), // warning/error number + OptionalParser(SingleGlyphTypeParser(Glyph.LargeSymbol::class)), // stop symbol (only with errors) + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CHECK)) + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + + return when (parseResult.valueAtOrNull(0)) { + Glyph.LargeSymbol(LargeSymbol.WARNING) -> { + ParseResult.Value(ParsedScreen.AlertScreen( + AlertScreenContent.Warning(parseResult.valueAt(2)) + )) + } + + Glyph.LargeSymbol(LargeSymbol.ERROR) -> { + ParseResult.Value(ParsedScreen.AlertScreen( + AlertScreenContent.Error(parseResult.valueAt(2)) + )) + } + + else -> ParseResult.Value(ParsedScreen.AlertScreen(AlertScreenContent.None)) + } + } +} + +class QuickinfoScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphTypeParser(Glyph.LargeSymbol::class), + IntegerParser() + ) + ).parse(parseContext) + + parseResult as ParseResult.Sequence + + val reservoirState = when (parseResult.valueAt(0)) { + Glyph.LargeSymbol(LargeSymbol.RESERVOIR_EMPTY) -> ReservoirState.EMPTY + Glyph.LargeSymbol(LargeSymbol.RESERVOIR_LOW) -> ReservoirState.LOW + Glyph.LargeSymbol(LargeSymbol.RESERVOIR_FULL) -> ReservoirState.FULL + else -> return ParseResult.Failed + } + + val availableUnits = parseResult.valueAt(1) + + return ParseResult.Value( + ParsedScreen.QuickinfoMainScreen( + Quickinfo(availableUnits = availableUnits, reservoirState = reservoirState) + ) + ) + } +} + +class BasalRateTotalScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL_SET)), + DecimalParser(), + SingleGlyphParser(Glyph.LargeCharacter('u')), + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CHECK)), + SingleGlyphTypeParser(Glyph.SmallCharacter::class) + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + + return ParseResult.Value( + ParsedScreen.BasalRateTotalScreen( + totalNumUnits = parseResult.valueAt(0), + basalRateNumber = parseResult.valueAt(1) + ) + ) + } +} + +class TemporaryBasalRatePercentageScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL)), + OptionalParser(IntegerParser()), // TBR percentage + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.PERCENT)), + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW)), + TimeParser() + ), + allowIncompleteSequences = true + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + + val remainingTbrDurationParseResult = if (parseResult.size >= 2) + parseResult.valueAtOrNull(1) + else + null + val remainingTbrDurationInMinutes = remainingTbrDurationParseResult?.let { it.hour * 60 + it.minute } ?: 0 + + return ParseResult.Value( + ParsedScreen.TemporaryBasalRatePercentageScreen( + percentage = parseResult.valueAtOrNull(0), + remainingDurationInMinutes = remainingTbrDurationInMinutes + ) + ) + } +} + +class TemporaryBasalRateDurationScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.ARROW)), + OptionalParser(TimeParser()) + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + val durationParseResult = parseResult.valueAtOrNull(0) + + return ParseResult.Value( + ParsedScreen.TemporaryBasalRateDurationScreen( + durationInMinutes = if (durationParseResult != null) + durationParseResult.hour * 60 + durationParseResult.minute + else + null + ) + ) + } +} + +class NormalMainScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + require(parseContext.topLeftTime != null) + + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL)), + DecimalParser(), // Current basal rate factor + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.UNITS_PER_HOUR)), + SingleGlyphTypeParser(Glyph.SmallDigit::class), // Basal rate number, + SingleGlyphTypeParser(Glyph.SmallSymbol::class) // Battery state + ), + allowIncompleteSequences = true + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + if (parseResult.size < 2) + return ParseResult.Failed + + val batteryState = batteryStateFromSymbol( + if (parseResult.size >= 3) parseResult.valueAt(2).symbol else null + ) + + return ParseResult.Value( + ParsedScreen.MainScreen( + MainScreenContent.Normal( + currentTime = parseContext.topLeftTime!!, + activeBasalProfileNumber = parseResult.valueAt(1).digit, + currentBasalRateFactor = parseResult.valueAt(0), + batteryState = batteryState + ) + ) + ) + } +} + +class TbrMainScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + require(parseContext.topLeftTime != null) + + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW)), + TimeParser(), // Remaining TBR duration + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL)), + FirstSuccessParser( + listOf( + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.UP)), + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DOWN)) + ) + ), + IntegerParser(IntegerParser.Mode.LARGE_DIGITS_ONLY), // TBR percentage + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.PERCENT)), + SingleGlyphTypeParser(Glyph.SmallDigit::class), // Basal rate number + DecimalParser(), // Current basal rate factor + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.UNITS_PER_HOUR)), + SingleGlyphTypeParser(Glyph.SmallSymbol::class) // Battery state + ), + allowIncompleteSequences = true + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + if (parseResult.size < 4) + return ParseResult.Failed + + val batteryState = batteryStateFromSymbol( + if (parseResult.size >= 5) parseResult.valueAt(4).symbol else null + ) + + val remainingTbrDuration = parseResult.valueAt(0) + + return ParseResult.Value( + ParsedScreen.MainScreen( + MainScreenContent.Tbr( + currentTime = parseContext.topLeftTime!!, + remainingTbrDurationInMinutes = remainingTbrDuration.hour * 60 + remainingTbrDuration.minute, + tbrPercentage = parseResult.valueAt(1), + activeBasalProfileNumber = parseResult.valueAt(2).digit, + currentBasalRateFactor = parseResult.valueAt(3), + batteryState = batteryState + ) + ) + ) + } +} + +class ExtendedAndMultiwaveBolusMainScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + require(parseContext.topLeftTime != null) + + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW)), + TimeParser(), // Remaining extended/multiwave bolus duration + SingleGlyphTypeParser(Glyph.LargeSymbol::class), // Extended / multiwave symbol + OptionalParser(SingleGlyphTypeParser(Glyph.SmallSymbol::class)), // TBR arrow up/down symbol (only present if TBR is active) + DecimalParser(), // Remaining bolus amount + SingleGlyphParser(Glyph.LargeCharacter('u')), + SingleGlyphTypeParser(Glyph.SmallDigit::class), // Active basal rate number + DecimalParser(), // Current basal rate factor + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.UNITS_PER_HOUR)), + SingleGlyphTypeParser(Glyph.SmallSymbol::class) // Battery state + ), + allowIncompleteSequences = true + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + if (parseResult.size < 6) + return ParseResult.Failed + + // At that location, only the extended and multiwave bolus symbols + // are valid. Otherwise, this isn't an extended/multiwave bolus screen. + val isExtendedBolus = when (parseResult.valueAt(1).symbol) { + LargeSymbol.EXTENDED_BOLUS -> true + LargeSymbol.MULTIWAVE_BOLUS -> false + else -> return ParseResult.Failed + } + + val tbrIsActive = when (parseResult.valueAtOrNull(2)?.symbol) { + SmallSymbol.UP, + SmallSymbol.DOWN -> true + null -> false + else -> return ParseResult.Failed + } + + val batteryState = batteryStateFromSymbol( + if (parseResult.size >= 7) parseResult.valueAt(5).symbol else null + ) + + val remainingBolusDuration = parseResult.valueAt(0) + + return ParseResult.Value( + ParsedScreen.MainScreen( + MainScreenContent.ExtendedOrMultiwaveBolus( + currentTime = parseContext.topLeftTime!!, + remainingBolusDurationInMinutes = remainingBolusDuration.hour * 60 + remainingBolusDuration.minute, + isExtendedBolus = isExtendedBolus, + remainingBolusAmount = parseResult.valueAt(3), + tbrIsActive = tbrIsActive, + activeBasalProfileNumber = parseResult.valueAt(4).digit, + currentBasalRateFactor = parseResult.valueAt(5), + batteryState = batteryState + ) + ) + ) + } +} + +class StoppedMainScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + require(parseContext.topLeftTime != null) + + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)), + DateParser(), // Current date + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.STOP)), + SingleGlyphTypeParser(Glyph.SmallSymbol::class) // Battery state + ), + allowIncompleteSequences = true + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + if (parseResult.size < 1) + return ParseResult.Failed + + val currentDate = parseResult.valueAt(0) + + val batteryState = batteryStateFromSymbol( + if (parseResult.size >= 2) parseResult.valueAt(1).symbol else null + ) + + return ParseResult.Value( + ParsedScreen.MainScreen( + MainScreenContent.Stopped( + currentDateTime = currentDate.atTime( + hour = parseContext.topLeftTime!!.hour, + minute = parseContext.topLeftTime!!.minute, + second = 0, + nanosecond = 0 + ), + batteryState = batteryState + ) + ) + ) + } +} + +class BasalRateFactorSettingScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + require(parseContext.topLeftTime != null) + + val parseResult = SequenceParser( + listOf( + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.MINUS)), + TimeParser(), + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.BASAL)), + OptionalParser(DecimalParser()), + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.UNITS_PER_HOUR)), + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY) + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + val beginTime = parseContext.topLeftTime!! + val endTime = parseResult.valueAt(0) + val numUnits = parseResult.valueAtOrNull(1) + val basalRateNumber = parseResult.valueAt(2) + + return ParseResult.Value( + ParsedScreen.BasalRateFactorSettingScreen( + beginTime = beginTime, + endTime = endTime, + numUnits = numUnits, + basalRateNumber = basalRateNumber + ) + ) + } +} + +class TimeAndDateSettingsScreenParser(val titleId: TitleID) : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphTypeParser(Glyph.LargeSymbol::class), + OptionalParser(IntegerParser(IntegerParser.Mode.LARGE_DIGITS_ONLY)), // Quantity + OptionalParser(StringParser()) // AM/PM + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + val symbolGlyph = parseResult.valueAt(0) + val ampm = parseResult.valueAtOrNull(2) + var quantity = parseResult.valueAtOrNull(1) + + // The AM/PM -> 24 hour translation must only be attempted if the + // quantity is an hour. If it is a minute, this translation might + // incorrectly change minute 24 into minute 0 for example. + if ((titleId == TitleID.HOUR) && (quantity != null)) + quantity = amPmTo24Hour(quantity, ampm ?: "") + + val expectedSymbol = when (titleId) { + TitleID.HOUR, + TitleID.MINUTE -> LargeSymbol.CLOCK + TitleID.YEAR, + TitleID.MONTH, + TitleID.DAY -> LargeSymbol.CALENDAR + else -> return ParseResult.Failed + } + + if (symbolGlyph.symbol != expectedSymbol) + return ParseResult.Failed + + return ParseResult.Value( + when (titleId) { + TitleID.HOUR -> ParsedScreen.TimeAndDateSettingsHourScreen(quantity) + TitleID.MINUTE -> ParsedScreen.TimeAndDateSettingsMinuteScreen(quantity) + TitleID.YEAR -> ParsedScreen.TimeAndDateSettingsYearScreen(quantity) + TitleID.MONTH -> ParsedScreen.TimeAndDateSettingsMonthScreen(quantity) + TitleID.DAY -> ParsedScreen.TimeAndDateSettingsDayScreen(quantity) + else -> return ParseResult.Failed + } + ) + } +} + +class MyDataBolusDataScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphTypeParser(Glyph.SmallSymbol::class), // Bolus type + DecimalParser(), // Bolus amount, + SingleGlyphParser(Glyph.SmallCharacter('U')), + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Index + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DIVIDE)), + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Total num entries + OptionalParser(SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW))), + OptionalParser(TimeParser()), // Duration - only present in multiwave and extended bolus entries + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)), + TimeParser(), // Timestamp time + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)), + DateParser() // Timestamp date + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + val bolusType = when (parseResult.valueAt(0).symbol) { + SmallSymbol.BOLUS -> MyDataBolusType.STANDARD + SmallSymbol.MULTIWAVE_BOLUS -> MyDataBolusType.MULTI_WAVE + SmallSymbol.EXTENDED_BOLUS -> MyDataBolusType.EXTENDED + else -> return ParseResult.Failed + } + val bolusAmount = parseResult.valueAt(1) + val index = parseResult.valueAt(2) + val totalNumEntries = parseResult.valueAt(3) + val duration = parseResult.valueAtOrNull(4) + val timestamp = combinedDateTime( + date = parseResult.valueAt(6), + time = parseResult.valueAt(5) + ) + + return ParseResult.Value( + ParsedScreen.MyDataBolusDataScreen( + index = index, + totalNumEntries = totalNumEntries, + timestamp = timestamp, + bolusAmount = bolusAmount, + bolusType = bolusType, + durationInMinutes = if (duration != null) (duration.hour * 60 + duration.minute) else null + ) + ) + } +} + +class MyDataErrorDataScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphTypeParser(Glyph.SmallSymbol::class), // Alert type + SingleGlyphTypeParser(Glyph.SmallCharacter::class), // Alert letter ('W' or 'E') + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY, checkForWhitespace = true), // Alert number + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Index + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DIVIDE)), + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Total num entries + StringParser(), // Alert description - ignored + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)), + TimeParser(), // Timestamp time + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)), + DateParser() // Timestamp date + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + val alertType = parseResult.valueAt(0).symbol + // skipping value #1 (the alert letter) + val alertNumber = parseResult.valueAt(2) + val index = parseResult.valueAt(3) + val totalNumEntries = parseResult.valueAt(4) + // skipping value #5 (the alert description) + val timestamp = combinedDateTime( + date = parseResult.valueAt(7), + time = parseResult.valueAt(6) + ) + + return ParseResult.Value( + ParsedScreen.MyDataErrorDataScreen( + index = index, + totalNumEntries = totalNumEntries, + timestamp = timestamp, + alert = if (alertType == SmallSymbol.WARNING) AlertScreenContent.Warning(alertNumber) else AlertScreenContent.Error(alertNumber) + ) + ) + } +} + +class MyDataDailyTotalsScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Index + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DIVIDE)), + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Total num entries + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.SUM)), + DecimalParser(), // Total daily amount + SingleGlyphParser(Glyph.SmallCharacter('U')), + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)), + DateParser() // Timestamp date + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + val index = parseResult.valueAt(0) + val totalNumEntries = parseResult.valueAt(1) + val totalDailyAmount = parseResult.valueAt(2) + val date = parseResult.valueAt(3) + + return ParseResult.Value( + ParsedScreen.MyDataDailyTotalsScreen( + index = index, + totalNumEntries = totalNumEntries, + date = date, + totalDailyAmount = totalDailyAmount + ) + ) + } +} + +class MyDataTbrDataScreenParser : Parser() { + override fun parseImpl(parseContext: ParseContext): ParseResult { + val parseResult = SequenceParser( + listOf( + SingleGlyphTypeParser(Glyph.SmallSymbol::class), // TBR type - is ignored (it only indicates whether or not TBR was < or > 100%) + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Percentage + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.PERCENT)), + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Index + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.DIVIDE)), + IntegerParser(IntegerParser.Mode.SMALL_DIGITS_ONLY), // Total num entries + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.ARROW)), + TimeParser(), // Duration + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)), + TimeParser(), // Timestamp time + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CALENDAR)), + DateParser() // Timestamp date + ) + ).parse(parseContext) + + if (!parseResult.isSuccess) + return ParseResult.Failed + + parseResult as ParseResult.Sequence + val percentage = parseResult.valueAt(1) + val index = parseResult.valueAt(2) + val totalNumEntries = parseResult.valueAt(3) + val duration = parseResult.valueAt(4) + val timestamp = combinedDateTime( + date = parseResult.valueAt(6), + time = parseResult.valueAt(5) + ) + + return ParseResult.Value( + ParsedScreen.MyDataTbrDataScreen( + index = index, + totalNumEntries = totalNumEntries, + timestamp = timestamp, + percentage = percentage, + durationInMinutes = duration.hour * 60 + duration.minute + ) + ) + } +} diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Pattern.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Pattern.kt new file mode 100644 index 0000000000..dba6ec1f14 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Pattern.kt @@ -0,0 +1,2034 @@ +package info.nightscout.comboctl.parser + +/** + * Two-dimensional binary pattern for searching in display frames. + * + * This stores pixels of a pattern as a boolean array. These pixels are + * immutable and used for parsing display frames coming from the Combo. + * + * The pattern is not directly constructed out of a boolean array, since + * that is impractical. Rather, it is constructed out of an array of strings. + * This array is the "template" for the pattern, and its items are the "rows". + * A whitespace character is interpreted as the boolean value "false", any + * other character as "true". This makes it much easier to hardcode a pattern + * template in a human-readable form. All template rows must have the exact + * same length (at least 1 character), since patterns are rectangular. The + * width property is derived from the length of the rows, while the height + * equals the number of rows. + * + * The pixels BooleanArray contains the actual pixels, which are stored in + * row-major order. That is: Given coordinates x and y (both starting at 0), + * then the corresponding index in the array is (x + y * width). + * + * Pixels whose value is "true" are considered to be "set", while pixels with + * the value "false" are considered to be "cleared". The number of set pixels + * is available via the numSetPixels property. This amount is used when + * resolving pattern match overlaps to decide if one of the overlapping matches + * "wins" and the other has to be ignored. + * + * @param templateRows The string rows that make up the template. + * @property width Width of the pattern, in pixels. + * @property height Height of the pattern, in pixels. + * @property pixels Boolean array housing the pixels. + * @property numSetPixels Number of pixels in the array that are set + * (= whose value is true). + */ +class Pattern(templateRows: Array) { + val width: Int + val height: Int + val pixels: BooleanArray + val numSetPixels: Int + + init { + // Sanity checks. The pattern must have at least one row, + // and rows must not be empty. + height = templateRows.size + if (height < 1) + throw IllegalArgumentException("Could not generate pattern; no template rows available)") + + width = templateRows[0].length + if (height < 1) + throw IllegalArgumentException("Could not generate pattern; empty template row detected") + + // Initialize the pixels array and count the number of pixels. + // The latter will be needed during pattern matching in case + // matched patterns overlap in the display frame. + + pixels = BooleanArray(width * height) { false } + + var tempNumSetPixels = 0 + + templateRows.forEachIndexed { y, row -> + // Sanity check in case the pattern is malformed and + // this row is of different length than the others. + if (row.length != width) + throw IllegalArgumentException( + "Not all rows are of equal length; row #0: $width row #$y: ${row.length}" + ) + + // Fill the pixel array with pixels from the template rows. + // These contain whitespace for clear pixels and something + // else (typically a solid block character) for set pixels. + for (x in 0 until width) { + val pixel = row[x] != ' ' + pixels[x + y * width] = pixel + if (pixel) + tempNumSetPixels++ + } + } + + numSetPixels = tempNumSetPixels + } +} + +/** + * Available small symbol glyphs. + */ +enum class SmallSymbol { + CLOCK, + LOCK_CLOSED, + LOCK_OPENED, + CHECK, + LOW_BATTERY, + NO_BATTERY, + WARNING, + DIVIDE, + RESERVOIR_LOW, + RESERVOIR_EMPTY, + CALENDAR, + SEPARATOR, + ARROW, + UNITS_PER_HOUR, + BOLUS, + MULTIWAVE_BOLUS, + SPEAKER, + ERROR, + DOT, + UP, + DOWN, + SUM, + BRACKET_RIGHT, + BRACKET_LEFT, + EXTENDED_BOLUS, + PERCENT, + BASAL, + MINUS, + WARRANTY, +} + +/** + * Available large symbol glyphs. + */ +enum class LargeSymbol { + CLOCK, + CALENDAR, + DOT, + SEPARATOR, + WARNING, + PERCENT, + UNITS_PER_HOUR, + BASAL_SET, + RESERVOIR_FULL, + RESERVOIR_LOW, + RESERVOIR_EMPTY, + ARROW, + STOP, + CALENDAR_AND_CLOCK, + TBR, + BOLUS, + MULTIWAVE_BOLUS, + MULTIWAVE_BOLUS_IMMEDIATE, + EXTENDED_BOLUS, + BLUETOOTH_SETTINGS, + THERAPY_SETTINGS, + PUMP_SETTINGS, + MENU_SETTINGS, + BASAL, + MY_DATA, + REMINDER_SETTINGS, + CHECK, + ERROR +} + +/** + * Class specifying a glyph. + * + * A "glyph" is a character, digit, or symbol for which a pattern exists that + * can be search for in a Combo display frame. Glyphs can be "small" or "large" + * (this primarily refers to the glyph's height). During pattern matching, + * if matches overlap, and one match is for a small glyph and the other is for + * a large glyph, the large one "wins", and the small match is ignored. + * + * By using the sealed class and its subclasses, it becomes possible to add + * context to the hard-coded patterns below. When a pattern matches a subregion + * in a frame, the corresponding Glyph subclass informs about what the discovered + * subregion stands for. + * + * @property isLarge true if this is a "large" glyph. + */ +sealed class Glyph(val isLarge: Boolean) { + data class SmallDigit(val digit: Int) : Glyph(false) + data class SmallCharacter(val character: Char) : Glyph(false) + data class SmallSymbol(val symbol: info.nightscout.comboctl.parser.SmallSymbol) : Glyph(false) + data class LargeDigit(val digit: Int) : Glyph(true) + data class LargeCharacter(val character: Char) : Glyph(true) + data class LargeSymbol(val symbol: info.nightscout.comboctl.parser.LargeSymbol) : Glyph(true) +} + +/** + * Map of hard-coded patterns, each associated with a glyph specifying what the pattern stands for. + */ +val glyphPatterns = mapOf( + Glyph.LargeSymbol(LargeSymbol.CLOCK) to Pattern(arrayOf( + " ", + " █████ ", + " ██ ██ ", + " █ █ █ ", + " █ █ ██ ", + "█ █ █ ", + "█ █ ██", + "█ ████ ██", + "█ ██", + "█ ██", + " █ ███", + " █ ██ ", + " ██ ████ ", + " █████████ ", + " █████ " + )), + Glyph.LargeSymbol(LargeSymbol.CALENDAR) to Pattern(arrayOf( + " ", + "█████████████ ", + "█ ██", + "██████████████", + "█ █ █ █ █ █ ██", + "██████████████", + "█ █ █ █ █ █ ██", + "██████████████", + "█ █ █ █ █ █ ██", + "██████████████", + "█ █ █ █ ██████", + "██████████████", + " █████████████", + " ", + " " + )), + Glyph.LargeSymbol(LargeSymbol.DOT) to Pattern(arrayOf( + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ███ ", + " ███ ", + " ███ " + )), + Glyph.LargeSymbol(LargeSymbol.SEPARATOR) to Pattern(arrayOf( + " ", + " ", + " ", + " ", + " ", + " ███ ", + " ███ ", + " ███ ", + " ", + " ", + " ███ ", + " ███ ", + " ███ ", + " ", + " " + )), + Glyph.LargeSymbol(LargeSymbol.WARNING) to Pattern(arrayOf( + " ██ ", + " ████ ", + " █ █ ", + " ██ ██ ", + " █ █ ", + " ██ ██ ██ ", + " █ ██ █ ", + " ██ ██ ██ ", + " █ ██ █ ", + " ██ ██ ██ ", + " █ █ ", + " ██ ██ ██ ", + " █ █ ", + "████████████████", + " ███████████████" + )), + Glyph.LargeSymbol(LargeSymbol.PERCENT) to Pattern(arrayOf( + " ██ ██", + "████ ██ ", + "████ ██ ", + " ██ ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██ ", + " ██ ████", + " ██ ████", + "██ ██ ", + " " + )), + Glyph.LargeSymbol(LargeSymbol.UNITS_PER_HOUR) to Pattern(arrayOf( + "██ ██ ██ ██ ", + "██ ██ ██ ██ ", + "██ ██ ██ ██ ", + "██ ██ ██ ██ ", + "██ ██ ██ █████ ", + "██ ██ ██ ███ ██", + "██ ██ ██ ██ ██", + "██ ██ ██ ██ ██", + "██ ██ ██ ██ ██", + "██ ██ ██ ██ ██", + "██ ██ ██ ██ ██", + " ████ ██ ██ ██" + )), + Glyph.LargeSymbol(LargeSymbol.BASAL_SET) to Pattern(arrayOf( + " ", + " ███████ ", + " ███████ ", + " ██ █ ██ ", + " ███ ████████", + " ██ █ ███████", + "████████ █ █ █ ██", + "███████ █ █ █ ███", + "██ █ █ █ █ █ █ ██", + "███ █ █ █ █ █ ███", + "██ █ █ █ █ █ █ ██", + "███ █ █ █ █ █ ███", + "██ █ █ █ █ █ █ ██", + "███ █ █ █ █ █ ███", + "██ █ █ █ █ █ █ ██" + )), + Glyph.LargeSymbol(LargeSymbol.RESERVOIR_FULL) to Pattern(arrayOf( + " ", + "████████████████████ ", + "████████████████████ ", + "████████████████████ ███", + "██████████████████████ █", + "██████████████████████ █", + "██████████████████████ █", + "██████████████████████ █", + "████████████████████ ███", + "████████████████████ ", + "████████████████████ ", + " " + )), + Glyph.LargeSymbol(LargeSymbol.RESERVOIR_LOW) to Pattern(arrayOf( + " ", + "████████████████████ ", + "█ █ █ █ ████ ", + "█ █ █ █ ████ ███", + "█ ██████ █", + "█ ██████ █", + "█ ██████ █", + "█ ██████ █", + "█ ████ ███", + "█ ████ ", + "████████████████████ ", + " " + )), + Glyph.LargeSymbol(LargeSymbol.RESERVOIR_EMPTY) to Pattern(arrayOf( + " ", + "████████████████████ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ███", + "█ ███ █", + "█ █ █", + "█ █ █", + "█ ███ █", + "█ █ ███", + "█ █ ", + "████████████████████ ", + " " + )), + Glyph.LargeSymbol(LargeSymbol.ARROW) to Pattern(arrayOf( + " ", + " ██ ", + " ███ ", + " ████ ", + " █████ ", + " ██████ ", + "███████████████ ", + "████████████████", + "████████████████", + "███████████████ ", + " ██████ ", + " █████ ", + " ████ ", + " ███ ", + " ██ " + )), + Glyph.LargeSymbol(LargeSymbol.EXTENDED_BOLUS) to Pattern(arrayOf( + " ", + "█████████████ ", + "█████████████ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ █████", + "██ █████" + )), + Glyph.LargeSymbol(LargeSymbol.MULTIWAVE_BOLUS) to Pattern(arrayOf( + " ", + "██████ ", + "██████ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ████████████", + "██ ████████████", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██" + )), + Glyph.LargeSymbol(LargeSymbol.BOLUS) to Pattern(arrayOf( + " ██████ ", + " ██████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + "█████ ████████", + "█████ ████████" + )), + Glyph.LargeSymbol(LargeSymbol.MULTIWAVE_BOLUS_IMMEDIATE) to Pattern(arrayOf( + "██████ ", + "██████ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ", + "██ ██ ██ ██ ██", + "██ ██ ██ ██ ██", + "██ ", + "██ ██", + "██ ██", + "██ ", + "██ ██", + "██ ██" + )), + Glyph.LargeSymbol(LargeSymbol.STOP) to Pattern(arrayOf( + " ████████ ", + " ██████████ ", + " ████████████ ", + " ██████████████ ", + "████████████████", + "█ █ █ █ ██", + "█ ███ ██ █ █ █ █", + "█ ██ ██ █ █ ██", + "██ ██ ██ █ █ ███", + "█ ██ ██ █ ███", + "████████████████", + " ██████████████ ", + " ████████████ ", + " ██████████ ", + " ████████ " + )), + Glyph.LargeSymbol(LargeSymbol.CALENDAR_AND_CLOCK) to Pattern(arrayOf( + " █████ ", + " █ █ ", + "██████ █ █ ", + "█ █ █ █ ", + "█████ █ █ ", + "█ █ █ ███ █ ", + "█████ ", + "█ █ █ ███████", + "██████ █ █", + "█ █ █ █ █ ██", + "█████████ █ █ █", + "█ █ █ █ █ ██ █ █", + "█████████ █ █ █", + " ████████ ███████" + )), + Glyph.LargeSymbol(LargeSymbol.TBR) to Pattern(arrayOf( + " ███████ ██ ██", + " ███████ ████ ██ ", + " ██ ██ ████ ██ ", + " ██ ███████ ██ ██ ", + " ██ ███████ ██ ", + "███████ ██ ██ ██ ", + "███████ ██ ██ ██ ", + "██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ████", + "██ ██ ██ ██ ██ ████", + "██ ██ ██ ██ ██ ██ " + )), + Glyph.LargeSymbol(LargeSymbol.BASAL) to Pattern(arrayOf( + " ", + " ███████ ", + " ███████ ", + " ██ ██ ", + " ██ ███████", + " ██ ███████", + "███████ ██ ██", + "███████ ██ ██", + "██ ██ ██ ██", + "██ ██ ██ ██", + "██ ██ ██ ██", + "██ ██ ██ ██", + "██ ██ ██ ██", + "██ ██ ██ ██", + "██ ██ ██ ██" + )), + Glyph.LargeSymbol(LargeSymbol.PUMP_SETTINGS) to Pattern(arrayOf( + "███████████ ", + "███████████ ", + "████████████ ", + "██ ███ ", + "██ ████ ", + "█████████████ ", + "██ ██████ ", + "███████████████ ██", + "███████████████ █", + " ██", + " █ █ █", + " ██ █ █", + " █ █ █", + " ███████" + )), + Glyph.LargeSymbol(LargeSymbol.PUMP_SETTINGS) to Pattern(arrayOf( + "███████████ ", + "███████████ ", + "████████████ ", + "██ ███ ", + "██ ████ ", + "█████████████ ", + "██ ██████ ", + "███████████████ ██", + "███████████████ █", + " ██", + " █ █ █", + " ██ █ █", + " █ █ █", + " ███████" + )), + Glyph.LargeSymbol(LargeSymbol.THERAPY_SETTINGS) to Pattern(arrayOf( + " ████ ", + " █ █ ", + " █ █ ", + "████ ████ ", + "█ █ ", + "█ █ ", + "████ ████ ", + " █ █ ", + " █ █ ███████", + " ████ █ █", + " █ ██", + " █ █ █", + " ██ █ █", + " █ █ █", + " ███████" + )), + Glyph.LargeSymbol(LargeSymbol.BLUETOOTH_SETTINGS) to Pattern(arrayOf( + " ██████ ", + " ███ ████ ", + " ███ ███ ", + "████ █ ███ ", + "████ ██ ██ ", + "██ █ █ ███ ", + "███ ████ ", + "████ ██ ", + "███ █ ███████", + "██ █ █ █ █", + "████ ██ █ ██", + "████ █ █ █ █", + " ███ █ ██ █ █", + " ███ ██ █ █ █", + " █████ ███████" + )), + Glyph.LargeSymbol(LargeSymbol.MENU_SETTINGS) to Pattern(arrayOf( + " █████████ ", + " █ █ ", + " █ █ ", + "█████████ █ ", + "█████████ █ ", + "█████████ █ ", + "█████████ █ ", + "█████ ", + "█████ ███████", + "█████ █ █", + "█████ █ ██", + "█████ █ █ █", + "█████ ██ █ █", + "█████ █ █ █", + " ███████" + )), + Glyph.LargeSymbol(LargeSymbol.MY_DATA) to Pattern(arrayOf( + " ████ ", + " ██████ ", + " ████████ ", + " ██ ██ ", + " █ ", + "███████ █ ", + "█ █ █ ", + "█ ███ █ ███ ", + "█ █ ", + "█ ███ █ ████ ", + "█ █ █████ ", + "█ █ ██████", + "███████ ██████" + )), + Glyph.LargeSymbol(LargeSymbol.REMINDER_SETTINGS) to Pattern(arrayOf( + " █ ", + " █ █ ", + " ███ ", + " █ █ █ ", + " █ █ █ ", + " █ █ ██ ", + " █ █ █ ", + " █ █ █ ", + " █ █ ███████", + " █ █ █ █ █", + " █ █ █ ██", + "█████████ █ █ █", + " ███ ██ █ █", + " █ █ █ █", + " ███████" + )), + Glyph.LargeSymbol(LargeSymbol.CHECK) to Pattern(arrayOf( + " ███", + " ███ ", + " ███ ", + " ███ ", + "███ ███ ", + " ███ ███ ", + " ███ ███ ", + " █████ ", + " ███ ", + " █ " + )), + Glyph.LargeSymbol(LargeSymbol.ERROR) to Pattern(arrayOf( + " █████ ", + " █████████ ", + " ███████████ ", + " ███ █████ ███ ", + " ██ ███ ██ ", + "████ █ ████", + "█████ █████", + "██████ ██████", + "█████ █████", + "████ █ ████", + " ██ ███ ██ ", + " ███ █████ ███ ", + " ███████████ ", + " █████████ ", + " █████ " + )), + + Glyph.LargeDigit(0) to Pattern(arrayOf( + " ████ ", + " ██ ██ ", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + " ██ ██ ", + " ████ " + )), + Glyph.LargeDigit(1) to Pattern(arrayOf( + " ██ ", + " ███ ", + " ████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ " + )), + Glyph.LargeDigit(2) to Pattern(arrayOf( + " ████ ", + " ██ ██ ", + "██ ██", + "██ ██", + " ██", + " ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + "██ ", + "██ ", + "██ ", + "████████" + )), + Glyph.LargeDigit(3) to Pattern(arrayOf( + " █████ ", + "██ ██ ", + " ██", + " ██", + " ██", + " ██ ", + " ███ ", + " ██ ", + " ██", + " ██", + " ██", + " ██", + " ██", + "██ ██ ", + " █████ " + + )), + Glyph.LargeDigit(4) to Pattern(arrayOf( + " ██ ", + " ███ ", + " ███ ", + " ████ ", + " █ ██ ", + " ██ ██ ", + " █ ██ ", + " ██ ██ ", + "██ ██ ", + "████████", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ " + + )), + Glyph.LargeDigit(5) to Pattern(arrayOf( + "███████ ", + "██ ", + "██ ", + "██ ", + "██ ", + "██████ ", + " ██ ", + " ██", + " ██", + " ██", + " ██", + " ██", + " ██", + "██ ██ ", + " █████ " + )), + Glyph.LargeDigit(6) to Pattern(arrayOf( + " ███ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + "██ ", + "██████ ", + "███ ██ ", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + " ██ ██ ", + " ████ " + )), + Glyph.LargeDigit(7) to Pattern(arrayOf( + "████████", + " ██", + " ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ " + )), + Glyph.LargeDigit(8) to Pattern(arrayOf( + " ████ ", + " ██ ██ ", + "██ ██", + "██ ██", + "██ ██", + " ██ ██ ", + " ████ ", + " ██ ██ ", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + " ██ ██ ", + " ████ " + )), + Glyph.LargeDigit(9) to Pattern(arrayOf( + " ████ ", + " ██ ██ ", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + " ██ ███", + " ██████", + " ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ███ " + )), + + Glyph.LargeCharacter('E') to Pattern(arrayOf( + "████████", + "██ ", + "██ ", + "██ ", + "██ ", + "██ ", + "██ ", + "███████ ", + "██ ", + "██ ", + "██ ", + "██ ", + "██ ", + "██ ", + "████████" + )), + Glyph.LargeCharacter('W') to Pattern(arrayOf( + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██ ██", + "██ ██ ██", + "██ ██ ██", + "██ ██ ██", + "██ ██ ██", + "██ ██ ██", + "██ ██ ██", + "██ ████ ██", + "██████████", + " ███ ███ ", + " █ █ " + )), + Glyph.LargeCharacter('u') to Pattern(arrayOf( + " ", + " ", + " ", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + "██ ██", + " ████ " + )), + + Glyph.SmallSymbol(SmallSymbol.CLOCK) to Pattern(arrayOf( + " ███ ", + " █ █ █ ", + "█ █ █", + "█ ██ █", + "█ █", + " █ █ ", + " ███ " + )), + Glyph.SmallSymbol(SmallSymbol.UNITS_PER_HOUR) to Pattern(arrayOf( + "█ █ █ █ ", + "█ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █ ██ █", + "█ █ █ █ █", + "█ █ █ █ █", + " ██ █ █ █" + )), + Glyph.SmallSymbol(SmallSymbol.LOCK_CLOSED) to Pattern(arrayOf( + " ███ ", + "█ █", + "█ █", + "█████", + "██ ██", + "██ ██", + "█████" + )), + Glyph.SmallSymbol(SmallSymbol.LOCK_OPENED) to Pattern(arrayOf( + " ███ ", + "█ █ ", + "█ █ ", + " █████", + " ██ ██", + " ██ ██", + " █████" + )), + Glyph.SmallSymbol(SmallSymbol.CHECK) to Pattern(arrayOf( + " █", + " ██", + "█ ██ ", + "███ ", + " █ ", + " ", + " " + )), + Glyph.SmallSymbol(SmallSymbol.DIVIDE) to Pattern(arrayOf( + " ", + " █", + " █ ", + " █ ", + " █ ", + "█ ", + " " + )), + Glyph.SmallSymbol(SmallSymbol.LOW_BATTERY) to Pattern(arrayOf( + "██████████ ", + "█ █ ", + "███ ██", + "███ █", + "███ ██", + "█ █ ", + "██████████ " + + )), + Glyph.SmallSymbol(SmallSymbol.NO_BATTERY) to Pattern(arrayOf( + "██████████ ", + "█ █ ", + "█ ██", + "█ █", + "█ ██", + "█ █ ", + "██████████ " + + )), + Glyph.SmallSymbol(SmallSymbol.RESERVOIR_LOW) to Pattern(arrayOf( + "█████████████ ", + "█ █ █ █ ██ ███", + "█ █ █ █ ████ █", + "█ ████ █", + "█ ████ █", + "█ ██ ███", + "█████████████ " + )), + Glyph.SmallSymbol(SmallSymbol.RESERVOIR_EMPTY) to Pattern(arrayOf( + "█████████████ ", + "█ █ █ █ █ ███", + "█ █ █ █ ███ █", + "█ █ █", + "█ ███ █", + "█ █ ███", + "█████████████ " + )), + Glyph.SmallSymbol(SmallSymbol.CALENDAR) to Pattern(arrayOf( + "███████", + "█ █", + "███████", + "█ █ █ █", + "███████", + "█ █ ███", + "███████" + )), + Glyph.SmallSymbol(SmallSymbol.DOT) to Pattern(arrayOf( + " ", + " ", + " ", + " ", + " ", + " ██ ", + " ██ " + )), + Glyph.SmallSymbol(SmallSymbol.SEPARATOR) to Pattern(arrayOf( + " ", + " ██ ", + " ██ ", + " ", + " ██ ", + " ██ ", + " " + )), + Glyph.SmallSymbol(SmallSymbol.ARROW) to Pattern(arrayOf( + " █ ", + " ██ ", + "███████ ", + "████████", + "███████ ", + " ██ ", + " █ " + )), + Glyph.SmallSymbol(SmallSymbol.DOWN) to Pattern(arrayOf( + " ███ ", + " ███ ", + " ███ ", + "███████", + " █████ ", + " ███ ", + " █ " + )), + Glyph.SmallSymbol(SmallSymbol.UP) to Pattern(arrayOf( + " █ ", + " ███ ", + " █████ ", + "███████", + " ███ ", + " ███ ", + " ███ " + + )), + Glyph.SmallSymbol(SmallSymbol.SUM) to Pattern(arrayOf( + "██████", + "█ █", + " █ ", + " █ ", + " █ ", + "█ █", + "██████" + )), + Glyph.SmallSymbol(SmallSymbol.BOLUS) to Pattern(arrayOf( + " ███ ", + " █ █ ", + " █ █ ", + " █ █ ", + " █ █ ", + " █ █ ", + "██ ████" + )), + Glyph.SmallSymbol(SmallSymbol.MULTIWAVE_BOLUS) to Pattern(arrayOf( + "███ ", + "█ █ ", + "█ █ ", + "█ ██████", + "█ █", + "█ █", + "█ █" + )), + Glyph.SmallSymbol(SmallSymbol.EXTENDED_BOLUS) to Pattern(arrayOf( + "███████ ", + "█ █ ", + "█ █ ", + "█ █ ", + "█ █ ", + "█ █ ", + "█ ██" + )), + Glyph.SmallSymbol(SmallSymbol.SPEAKER) to Pattern(arrayOf( + " ██ ", + " █ █ ", + "██ █ ", + "██ ██", + "██ █ ", + " █ █ ", + " ██ " + )), + Glyph.SmallSymbol(SmallSymbol.ERROR) to Pattern(arrayOf( + " ███ ", + " █████ ", + "██ █ ██", + "███ ███", + "██ █ ██", + " █████ ", + " ███ " + )), + Glyph.SmallSymbol(SmallSymbol.WARNING) to Pattern(arrayOf( + " █ ", + " ███ ", + " █ █ ", + " █ █ █ ", + " █ █ ", + "█ █ █", + "███████" + )), + Glyph.SmallSymbol(SmallSymbol.BRACKET_LEFT) to Pattern(arrayOf( + " █ ", + " █ ", + " █ ", + " █ ", + " █ ", + " █ ", + " █ ", + " " + )), + Glyph.SmallSymbol(SmallSymbol.BRACKET_RIGHT) to Pattern(arrayOf( + " █ ", + " █ ", + " █ ", + " █ ", + " █ ", + " █ ", + " █ ", + " " + )), + Glyph.SmallSymbol(SmallSymbol.PERCENT) to Pattern(arrayOf( + "██ ", + "██ █", + " █ ", + " █ ", + " █ ", + "█ ██", + " ██" + )), + Glyph.SmallSymbol(SmallSymbol.BASAL) to Pattern(arrayOf( + " ████ ", + " █ ███", + "███ █ █", + "█ █ █ █", + "█ █ █ █", + "█ █ █ █", + "█ █ █ █" + )), + Glyph.SmallSymbol(SmallSymbol.MINUS) to Pattern(arrayOf( + " ", + " ", + " ", + " █████ ", + " ", + " ", + " " + )), + Glyph.SmallSymbol(SmallSymbol.WARRANTY) to Pattern(arrayOf( + " ███ █ ", + " ██ █ ", + " █ █ █", + "█ █", + "█ █ █ ", + " █ ██ ", + " █ ███ " + )), + + Glyph.SmallDigit(0) to Pattern(arrayOf( + " ███ ", + "█ █", + "█ ██", + "█ █ █", + "██ █", + "█ █", + " ███ " + )), + Glyph.SmallDigit(1) to Pattern(arrayOf( + " █ ", + " ██ ", + " █ ", + " █ ", + " █ ", + " █ ", + " ███ " + )), + Glyph.SmallDigit(2) to Pattern(arrayOf( + " ███ ", + "█ █", + " █", + " █ ", + " █ ", + " █ ", + "█████" + )), + Glyph.SmallDigit(3) to Pattern(arrayOf( + "█████", + " █ ", + " █ ", + " █ ", + " █", + "█ █", + " ███ " + )), + Glyph.SmallDigit(4) to Pattern(arrayOf( + " █ ", + " ██ ", + " █ █ ", + "█ █ ", + "█████", + " █ ", + " █ " + + )), + Glyph.SmallDigit(5) to Pattern(arrayOf( + "█████", + "█ ", + "████ ", + " █", + " █", + "█ █", + " ███ " + )), + Glyph.SmallDigit(6) to Pattern(arrayOf( + " ██ ", + " █ ", + "█ ", + "████ ", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallDigit(7) to Pattern(arrayOf( + "█████", + " █", + " █ ", + " █ ", + " █ ", + " █ ", + " █ " + )), + Glyph.SmallDigit(8) to Pattern(arrayOf( + " ███ ", + "█ █", + "█ █", + " ███ ", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallDigit(9) to Pattern(arrayOf( + " ███ ", + "█ █", + "█ █", + " ████", + " █", + " █ ", + " ██ " + )), + + Glyph.SmallCharacter('A') to Pattern(arrayOf( + " █ ", + " █ █ ", + "█ █", + "█████", + "█ █", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('a') to Pattern(arrayOf( + " ███ ", + " █", + " ████", + "█ █", + " ████" + )), + Glyph.SmallCharacter('Ä') to Pattern(arrayOf( + "█ █", + " ███ ", + "█ █", + "█ █", + "█████", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('ă') to Pattern(arrayOf( + " █ █ ", + " █ ", + " █ ", + " █ █ ", + "█ █", + "█████", + "█ █" + )), + Glyph.SmallCharacter('Á') to Pattern(arrayOf( + " █ ", + " █ ", + " ███ ", + "█ █", + "█████", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('á') to Pattern(arrayOf( + " █ ", + " █ ", + " █ ", + " █ █ ", + "█ █", + "█████", + "█ █" + )), + Glyph.SmallCharacter('ã') to Pattern(arrayOf( + " █ █", + "█ ██ ", + " █ ", + " █ █ ", + "█ █", + "█████", + "█ █" + )), + Glyph.SmallCharacter('Ą') to Pattern(arrayOf( + " ███ ", + "█ █", + "█████", + "█ █", + "█ █", + " █ ", + " █" + )), + Glyph.SmallCharacter('Å') to Pattern(arrayOf( + " █ ", + " █ █ ", + " █ ", + " █ █ ", + "█ █", + "█████", + "█ █" + )), + + Glyph.SmallCharacter('æ') to Pattern(arrayOf( + " ████", + "█ █ ", + "█ █ ", + "████ ", + "█ █ ", + "█ █ ", + "█ ███" + )), + + Glyph.SmallCharacter('B') to Pattern(arrayOf( + "████ ", + "█ █", + "█ █", + "████ ", + "█ █", + "█ █", + "████ " + )), + Glyph.SmallCharacter('C') to Pattern(arrayOf( + " ███ ", + "█ █", + "█ ", + "█ ", + "█ ", + "█ █", + " ███ " + )), + Glyph.SmallCharacter('ć') to Pattern(arrayOf( + " █ ", + " █ ", + " ████", + "█ ", + "█ ", + "█ ", + " ████" + )), + Glyph.SmallCharacter('č') to Pattern(arrayOf( + " █ █ ", + " █ ", + " ████", + "█ ", + "█ ", + "█ ", + " ████" + )), + Glyph.SmallCharacter('Ç') to Pattern(arrayOf( + " ████", + "█ ", + "█ ", + "█ ", + " ████", + " █ ", + " ██ " + )), + + Glyph.SmallCharacter('D') to Pattern(arrayOf( + "███ ", + "█ █ ", + "█ █", + "█ █", + "█ █", + "█ █ ", + "███ " + )), + Glyph.SmallCharacter('E') to Pattern(arrayOf( + "█████", + "█ ", + "█ ", + "████ ", + "█ ", + "█ ", + "█████" + )), + Glyph.SmallCharacter('É') to Pattern(arrayOf( + " █ ", + " █ ", + "█████", + "█ ", + "████ ", + "█ ", + "█████" + )), + Glyph.SmallCharacter('Ê') to Pattern(arrayOf( + " █ ", + " █ █ ", + "█████", + "█ ", + "████ ", + "█ ", + "█████" + )), + Glyph.SmallCharacter('Ě') to Pattern(arrayOf( + " █ █ ", + " █ ", + "█████", + "█ ", + "████ ", + "█ ", + "█████" + )), + Glyph.SmallCharacter('ę') to Pattern(arrayOf( + "█████", + "█ ", + "████ ", + "█ ", + "█████", + " █ ", + " ██ " + )), + Glyph.SmallCharacter('F') to Pattern(arrayOf( + "█████", + "█ ", + "█ ", + "████ ", + "█ ", + "█ ", + "█ " + )), + Glyph.SmallCharacter('G') to Pattern(arrayOf( + " ███ ", + "█ █", + "█ ", + "█ ███", + "█ █", + "█ █", + " ████" + )), + Glyph.SmallCharacter('H') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + "█████", + "█ █", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('I') to Pattern(arrayOf( + " ███ ", + " █ ", + " █ ", + " █ ", + " █ ", + " █ ", + " ███ " + )), + Glyph.SmallCharacter('i') to Pattern(arrayOf( + " █ ", + " ", + "██ ", + " █ ", + " █ ", + " █ ", + "███" + )), + Glyph.SmallCharacter('í') to Pattern(arrayOf( + " █", + " █ ", + "███", + " █ ", + " █ ", + " █ ", + "███" + )), + Glyph.SmallCharacter('İ') to Pattern(arrayOf( + " █ ", + " ", + "███", + " █ ", + " █ ", + " █ ", + "███" + )), + + Glyph.SmallCharacter('J') to Pattern(arrayOf( + " ███", + " █ ", + " █ ", + " █ ", + " █ ", + "█ █ ", + " ██ " + )), + Glyph.SmallCharacter('K') to Pattern(arrayOf( + "█ █", + "█ █ ", + "█ █ ", + "██ ", + "█ █ ", + "█ █ ", + "█ █" + )), + Glyph.SmallCharacter('L') to Pattern(arrayOf( + "█ ", + "█ ", + "█ ", + "█ ", + "█ ", + "█ ", + "█████" + )), + Glyph.SmallCharacter('ł') to Pattern(arrayOf( + " █ ", + " █ ", + " █ █ ", + " ██ ", + "██ ", + " █ ", + " ████" + )), + Glyph.SmallCharacter('M') to Pattern(arrayOf( + "█ █", + "██ ██", + "█ █ █", + "█ █ █", + "█ █", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('N') to Pattern(arrayOf( + "█ █", + "█ █", + "██ █", + "█ █ █", + "█ ██", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('Ñ') to Pattern(arrayOf( + " █ █", + "█ ██ ", + "█ █", + "██ █", + "█ █ █", + "█ ██", + "█ █" + )), + Glyph.SmallCharacter('ň') to Pattern(arrayOf( + " █ █ ", + " █ ", + "█ █", + "██ █", + "█ █ █", + "█ ██", + "█ █" + )), + Glyph.SmallCharacter('ń') to Pattern(arrayOf( + " █ ", + " █ ", + "█ █", + "██ █", + "█ █ █", + "█ ██", + "█ █" + )), + + Glyph.SmallCharacter('O') to Pattern(arrayOf( + " ███ ", + "█ █", + "█ █", + "█ █", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallCharacter('Ö') to Pattern(arrayOf( + "█ █", + " ███ ", + "█ █", + "█ █", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallCharacter('ó') to Pattern(arrayOf( + " █ ", + " █ ", + " ███ ", + "█ █", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallCharacter('ø') to Pattern(arrayOf( + " █", + " ███ ", + " █ █ █", + " █ █ █", + " █ █ █", + " ███ ", + " █ " + )), + Glyph.SmallCharacter('ő') to Pattern(arrayOf( + " █ █", + "█ █ ", + " ███ ", + "█ █", + "█ █", + "█ █", + " ███ " + )), + + Glyph.SmallCharacter('P') to Pattern(arrayOf( + "████ ", + "█ █", + "█ █", + "████ ", + "█ ", + "█ ", + "█ " + )), + Glyph.SmallCharacter('Q') to Pattern(arrayOf( + " ███ ", + "█ █", + "█ █", + "█ █", + "█ █ █", + "█ █ ", + " ██ █" + )), + Glyph.SmallCharacter('R') to Pattern(arrayOf( + "████ ", + "█ █", + "█ █", + "████ ", + "█ █ ", + "█ █ ", + "█ █" + )), + Glyph.SmallCharacter('S') to Pattern(arrayOf( + " ████", + "█ ", + "█ ", + " ███ ", + " █", + " █", + "████ " + )), + Glyph.SmallCharacter('ś') to Pattern(arrayOf( + " █ ", + " █ ", + " ████", + "█ ", + " ███ ", + " █", + "████ " + )), + Glyph.SmallCharacter('š') to Pattern(arrayOf( + " █ █ ", + " █ ", + " ████", + "█ ", + " ███ ", + " █", + "████ " + )), + + Glyph.SmallCharacter('T') to Pattern(arrayOf( + "█████", + " █ ", + " █ ", + " █ ", + " █ ", + " █ ", + " █ " + )), + Glyph.SmallCharacter('U') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + "█ █", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallCharacter('u') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + "█ ██", + " ██ █", + " " + )), + Glyph.SmallCharacter('Ü') to Pattern(arrayOf( + "█ █", + " ", + "█ █", + "█ █", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallCharacter('ú') to Pattern(arrayOf( + " █ ", + " █ ", + "█ █", + "█ █", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallCharacter('ů') to Pattern(arrayOf( + " █ ", + " █ █ ", + "█ █ █", + "█ █", + "█ █", + "█ █", + " ███ " + )), + Glyph.SmallCharacter('V') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + "█ █", + "█ █", + " █ █ ", + " █ " + )), + Glyph.SmallCharacter('W') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + "█ █ █", + "█ █ █", + "█ █ █", + " █ █ " + )), + Glyph.SmallCharacter('X') to Pattern(arrayOf( + "█ █", + "█ █", + " █ █ ", + " █ ", + " █ █ ", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('Y') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + " █ █ ", + " █ ", + " █ ", + " █ " + )), + Glyph.SmallCharacter('ý') to Pattern(arrayOf( + " █ ", + "█ █ █", + "█ █", + " █ █ ", + " █ ", + " █ ", + " █ " + )), + Glyph.SmallCharacter('Z') to Pattern(arrayOf( + "█████", + " █", + " █ ", + " █ ", + " █ ", + "█ ", + "█████" + )), + Glyph.SmallCharacter('ź') to Pattern(arrayOf( + " █ ", + "█████", + " █", + " ██ ", + " █ ", + "█ ", + "█████" + )), + Glyph.SmallCharacter('ž') to Pattern(arrayOf( + " █ █ ", + " █ ", + "█████", + " █ ", + " █ ", + " █ ", + "█████" + )), + + Glyph.SmallCharacter('б') to Pattern(arrayOf( + "█████", + "█ ", + "█ ", + "████ ", + "█ █", + "█ █", + "████ " + )), + Glyph.SmallCharacter('ъ') to Pattern(arrayOf( + "██ ", + " █ ", + " █ ", + " ██ ", + " █ █", + " █ █", + " ██ " + )), + Glyph.SmallCharacter('м') to Pattern(arrayOf( + "█ █", + "██ ██", + "█ █ █", + "█ █", + "█ █", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('л') to Pattern(arrayOf( + " ████", + " █ █", + " █ █", + " █ █", + " █ █", + " █ █", + "██ █" + )), + Glyph.SmallCharacter('ю') to Pattern(arrayOf( + "█ █ ", + "█ █ █", + "█ █ █", + "███ █", + "█ █ █", + "█ █ █", + "█ █ " + )), + Glyph.SmallCharacter('а') to Pattern(arrayOf( + " █ ", + " █ █ ", + "█ █", + "█ █", + "█████", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('п') to Pattern(arrayOf( + "█████", + "█ █", + "█ █", + "█ █", + "█ █", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('я') to Pattern(arrayOf( + " ████", + "█ █", + "█ █", + " ████", + " █ █", + " █ █", + "█ █" + )), + Glyph.SmallCharacter('й') to Pattern(arrayOf( + " █ █ ", + " █ ", + "█ █", + "█ ██", + "█ █ █", + "██ █", + "█ █" + )), + Glyph.SmallCharacter('Г') to Pattern(arrayOf( + "█████", + "█ ", + "█ ", + "█ ", + "█ ", + "█ ", + "█ " + )), + Glyph.SmallCharacter('д') to Pattern(arrayOf( + " ██ ", + " █ █ ", + " █ █ ", + "█ █ ", + "█ █ ", + "█████", + "█ █" + )), + Glyph.SmallCharacter('ь') to Pattern(arrayOf( + "█ ", + "█ ", + "█ ", + "███ ", + "█ █", + "█ █", + "███ " + )), + Glyph.SmallCharacter('ж') to Pattern(arrayOf( + "█ █ █", + "█ █ █", + " ███ ", + " ███ ", + "█ █ █", + "█ █ █", + "█ █ █" + )), + Glyph.SmallCharacter('ы') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + "██ █", + "█ █ █", + "█ █ █", + "██ █" + )), + Glyph.SmallCharacter('у') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + " ███ ", + " █ ", + " █ ", + "█ " + )), + Glyph.SmallCharacter('ч') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + "█ ██", + " ██ █", + " █", + " █" + )), + Glyph.SmallCharacter('з') to Pattern(arrayOf( + " ███ ", + " █ █", + " █", + " ██ ", + " █", + " █ █", + " ███ " + )), + Glyph.SmallCharacter('ц') to Pattern(arrayOf( + "█ █ ", + "█ █ ", + "█ █ ", + "█ █ ", + "█ █ ", + "█████", + " █" + )), + Glyph.SmallCharacter('и') to Pattern(arrayOf( + "█ █", + "█ ██", + "█ █ █", + "█ █ █", + "█ █ █", + "██ █", + "█ █" + )), + + Glyph.SmallCharacter('Σ') to Pattern(arrayOf( + "█████", + "█ ", + " █ ", + " █ ", + " █ ", + "█ ", + "█████" + )), + Glyph.SmallCharacter('Δ') to Pattern(arrayOf( + " █ ", + " █ ", + " █ █ ", + " █ █ ", + "█ █", + "█ █", + "█████" + )), + Glyph.SmallCharacter('Φ') to Pattern(arrayOf( + " █ ", + " ███ ", + "█ █ █", + "█ █ █", + "█ █ █", + " ███ ", + " █ " + )), + Glyph.SmallCharacter('Λ') to Pattern(arrayOf( + " █ ", + " █ █ ", + " █ █ ", + "█ █", + "█ █", + "█ █", + "█ █" + )), + Glyph.SmallCharacter('Ω') to Pattern(arrayOf( + " ███ ", + "█ █", + "█ █", + "█ █", + "█ █", + " █ █ ", + "██ ██" + )), + Glyph.SmallCharacter('υ') to Pattern(arrayOf( + "█ █", + "█ █", + "█ █", + " ███ ", + " █ ", + " █ ", + " █ " + )), + Glyph.SmallCharacter('Θ') to Pattern(arrayOf( + " ███ ", + "█ █", + "█ █", + "█ █ █", + "█ █", + "█ █", + " ███ " + )) +) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Quickinfo.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Quickinfo.kt new file mode 100644 index 0000000000..fe9765785a --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Quickinfo.kt @@ -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) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/TitleStrings.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/TitleStrings.kt new file mode 100644 index 0000000000..8b13c08d8e --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/TitleStrings.kt @@ -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, + "ЧАCЫ" to TitleID.HOUR, + "МИHУTЫ" 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 +) diff --git a/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Tokenization.kt b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Tokenization.kt new file mode 100644 index 0000000000..adf6828c91 --- /dev/null +++ b/pump/combov2/comboctl/src/commonMain/kotlin/info/nightscout/comboctl/parser/Tokenization.kt @@ -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 + +/** + * 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() + + // 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() + + // 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 +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CRCTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CRCTest.kt new file mode 100644 index 0000000000..dd2da00700 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CRCTest.kt @@ -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) + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CipherTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CipherTest.kt new file mode 100644 index 0000000000..3b65ac5fc9 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/CipherTest.kt @@ -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()) + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ComboFrameTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ComboFrameTest.kt new file mode 100644 index 0000000000..2b76314a01 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ComboFrameTest.kt @@ -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 0xCC 0xCC 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 { 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 { parser.parseFrame() } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/DisplayFrameTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/DisplayFrameTest.kt new file mode 100644 index 0000000000..e382abef7e --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/DisplayFrameTest.kt @@ -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) + } + } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/GraphTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/GraphTest.kt new file mode 100644 index 0000000000..d721f05773 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/GraphTest.kt @@ -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().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().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().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().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) + } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/NonceTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/NonceTest.kt new file mode 100644 index 0000000000..8d7cee1348 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/NonceTest.kt @@ -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) + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PairingSessionTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PairingSessionTest.kt new file mode 100644 index 0000000000..2fd989153b --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PairingSessionTest.kt @@ -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) : ComboIO { + private var curSequenceIndex = 0 + private val barrier = Channel(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) { + 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 { + 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") + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ProgressReporterTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ProgressReporterTest.kt new file mode 100644 index 0000000000..a1155d9bf4 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/ProgressReporterTest.kt @@ -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( + 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( + 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( + 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( + 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 + ) + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PumpIOTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PumpIOTest.kt new file mode 100644 index 0000000000..60ee33a5e4 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/PumpIOTest.kt @@ -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, + 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) { + 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) + } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TransportLayerTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TransportLayerTest.kt new file mode 100644 index 0000000000..a48ff86aca --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TransportLayerTest.kt @@ -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(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 { + // 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(exceptionThrownBySendCall.cause) + + val exceptionThrownByReceiveCall = assertFailsWith { + tpLayerIO.receive() + } + System.err.println( + "Exception thrown by receive() call (this exception was expected by the test): $exceptionThrownByReceiveCall" + ) + assertIs(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 { + coroutineScopeWithWatchdog(1000) { + tpLayerIO.receive() + } + } + + tpLayerIO.stop() + + assertEquals(1, numReceivedDataPackets) + } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TwofishTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TwofishTest.kt new file mode 100644 index 0000000000..5b17fe8db8 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/TwofishTest.kt @@ -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()) + } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestBluetoothDevice.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestBluetoothDevice.kt new file mode 100644 index 0000000000..2f65a90411 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestBluetoothDevice.kt @@ -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) { + frameParser.pushData(dataToSend) + frameParser.parseFrame()?.let { + runBlocking { + innerScope.async { + testComboIO.send(it) + }.await() + } + } + } + + override fun blockingReceive(): List = runBlocking { + innerScope.async { + val retval = testComboIO.receive().toComboFrame() + retval + }.await() + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestComboIO.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestComboIO.kt new file mode 100644 index 0000000000..7e5985a6ea --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestComboIO.kt @@ -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(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) + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPacketSequence.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPacketSequence.kt new file mode 100644 index 0000000000..3e0525494f --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPacketSequence.kt @@ -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 + +fun newTestPacketSequence() = mutableListOf() + +sealed class TestRefPacketItem { + data class TransportLayerPacketItem(val packetInfo: TransportLayer.OutgoingPacketInfo) : TestRefPacketItem() + data class ApplicationLayerPacketItem(val packet: ApplicationLayer.Packet) : TestRefPacketItem() +} + +fun checkTestPacketSequence(referenceSequence: List, testPacketSequence: List) { + 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) + } + } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPumpStateStore.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPumpStateStore.kt new file mode 100644 index 0000000000..141b6bc855 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/TestPumpStateStore.kt @@ -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() + 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 = 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 + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/UtilityFunctions.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/UtilityFunctions.kt new file mode 100644 index 0000000000..c552f6edd5 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/base/testUtils/UtilityFunctions.kt @@ -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 +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStreamTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStreamTest.kt new file mode 100644 index 0000000000..c602150952 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/ParsedDisplayFrameStreamTest.kt @@ -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() + 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() + 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() + 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() + 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 { + stream.feedDisplayFrame(testFrameW6CancelTbrWarningScreen) + stream.getParsedDisplayFrame(processAlertScreens = true) + } + assertIs(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() + 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) + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/RTNavigationTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/RTNavigationTest.kt new file mode 100644 index 0000000000..1ce48e48b6 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/main/RTNavigationTest.kt @@ -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, + 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(capacity = Channel.RENDEZVOUS) + private var longButtonJob: Job? = null + private var lastParsedScreen: ParsedScreen? = null + + val shortPressedRTButtons = mutableListOf() + + 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( + ParsedScreen.TemporaryBasalRateMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[0]) + assertEquals(PathSegment( + ParsedScreen.MyDataMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[1]) + assertEquals(PathSegment( + ParsedScreen.BasalRate1ProgrammingMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[2]) + assertEquals(PathSegment( + ParsedScreen.BasalRateTotalScreen::class, RTEdgeValue(RTNavigationButton.CHECK)), path[3]) + assertEquals(PathSegment( + 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( + ParsedScreen.MyDataMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[0]) + assertEquals(PathSegment( + ParsedScreen.BasalRate1ProgrammingMenuScreen::class, RTEdgeValue(RTNavigationButton.MENU)), path[1]) + assertEquals(PathSegment( + ParsedScreen.BasalRateTotalScreen::class, RTEdgeValue(RTNavigationButton.CHECK)), path[2]) + assertEquals(PathSegment( + 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 + + 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 + + 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 + + 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(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(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(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 { + // 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(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(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(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 { + runBlockingWithWatchdog(6000) { + adjustQuantityOnScreen( + rtNavigationContext, + targetQuantity = 160, + cyclicQuantityRange = null, + incrementSteps = arrayOf(Pair(0, 10)) + ) { parsedScreen -> + parsedScreen as ParsedScreen.TemporaryBasalRatePercentageScreen + parsedScreen.percentage + } + } + } + assertIs(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 + } + } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/ParserTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/ParserTest.kt new file mode 100644 index 0000000000..ed37965c2b --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/ParserTest.kt @@ -0,0 +1,1629 @@ +package info.nightscout.comboctl.parser + +import info.nightscout.comboctl.base.DisplayFrame +import info.nightscout.comboctl.base.timeWithoutDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.fail +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime + +class ParserTest { + class TestContext(displayFrame: DisplayFrame, tokenOffset: Int, skipTitleString: Boolean = false, parseTopLeftTime: Boolean = false) { + val tokens = findTokens(displayFrame) + val parseContext = ParseContext(tokens, tokenOffset) + + init { + if (skipTitleString) + StringParser().parse(parseContext) + if (parseTopLeftTime) + parseContext.topLeftTime = (TimeParser().parse(parseContext) as ParseResult.Value<*>).value as LocalDateTime + } + } + + // Tests for the basic parsers + + @Test + fun checkSingleGlyphParser() { + // Test the SingleGlyphParser by trying to parse token #9 + // with it. We expect that token to be Symbol.LARGE_RESERVOIR_FULL. + // When the SingleGlyphParser succeeds, it returns ParseResult.NoValue, + // since its purpose is to check that a particular glyph is at the + // current parse context position. There's no point in it having + // a return value, since its success is the information we want. + + val testContext = TestContext(testFrameQuickinfoMainScreen, 9) + val result = SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.RESERVOIR_FULL)).parse(testContext.parseContext) + assertEquals(ParseResult.NoValue::class, result::class) + } + + fun checkSingleGlyphTypeParser() { + // Test the SingleGlyphTypeParser by trying to parse token #9 + // with it. We expect that token to be Symbol.LARGE_RESERVOIR_FULL. + // When the SingleGlyphTypeParser succeeds, it returns the symbol + // as the result. Unlike SingleGlyphParser, this parser does not + // look for a particular glyph - it just tests that the *type* of + // the glyph matches. This is why it does have a result (the exact + // glpyh that was found). + + val testContext = TestContext(testFrameQuickinfoMainScreen, 9) + val result = SingleGlyphTypeParser(Glyph.LargeSymbol::class).parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + assertEquals(LargeSymbol.RESERVOIR_FULL, ((result as ParseResult.Value<*>).value as Glyph.LargeSymbol).symbol) + } + + @Test + fun checkTitleStringParser() { + val testContext = TestContext(testFrameQuickinfoMainScreen, 0) + val result = StringParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + assertEquals("QUICK INFO", (result as ParseResult.Value<*>).value as String) + } + + @Test + fun checkIntegerParser() { + val testContext = TestContext(testFrameQuickinfoMainScreen, 10) + val result = IntegerParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + assertEquals(213, (result as ParseResult.Value<*>).value as Int) + } + + @Test + fun checkDecimalParser() { + val testContext = TestContext(testFrameBasalRateTotalScreen1, 15) + val result = DecimalParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + assertEquals(56970, (result as ParseResult.Value<*>).value as Int) + } + + @Test + fun checkDateUSParser() { + val testContext = TestContext(testUSDateFormatScreen, 20) + val result = DateParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + assertEquals(LocalDate(year = 2011, monthNumber = 2, dayOfMonth = 3), (result as ParseResult.Value<*>).value as LocalDate) + } + + @Test + fun checkDateEUParser() { + val testContext = TestContext(testEUDateFormatScreen, 20) + val result = DateParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + assertEquals(LocalDate(year = 2011, monthNumber = 2, dayOfMonth = 3), (result as ParseResult.Value<*>).value as LocalDate) + } + + @Test + fun checkTime12HrParser() { + val testContext = TestContext(testTimeAndDateSettingsHour12hFormatScreen, 8) + val result = TimeParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + assertEquals(timeWithoutDate(hour = 20, minute = 34), (result as ParseResult.Value<*>).value as LocalDateTime) + } + + @Test + fun checkTime24HrParser() { + val testContext = TestContext(testTimeAndDateSettingsHour24hFormatScreen, 9) + val result = TimeParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + assertEquals(timeWithoutDate(hour = 10, minute = 22), (result as ParseResult.Value<*>).value as LocalDateTime) + } + + @Test + fun checkSuccessfulOptionalParser() { + // Test the OptionalParser by attempting to parse the + // second token in the test screen. That token is a + // small "1" digit. Here, we attempt to parse it as + // such a small digit. The OptionalParser should + // return the SingleGlyphTypeParser's result. + + val testContext = TestContext(testFrameMainScreenWithTimeSeparator, 1) + val result = OptionalParser( + SingleGlyphTypeParser(Glyph.SmallDigit::class) + ).parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + assertEquals(1, ((result as ParseResult.Value<*>).value as Glyph.SmallDigit).digit) + } + + @Test + fun checkFailedOptionalParser() { + // Test the OptionalParser by attempting to parse the + // second token in the test screen. That token is a + // small "1" digit. Here, we attempt to parse it as + // a small symbol instead of a digit to provoke a + // parsing failure. The OptionalParser should + // return ParseResult.Null as the result due to + // the SingleGlyphTypeParser's failure. + + val testContext = TestContext(testFrameMainScreenWithTimeSeparator, 1) + val result = OptionalParser( + SingleGlyphTypeParser(Glyph.SmallSymbol::class) + ).parse(testContext.parseContext) + + assertEquals(ParseResult.Null::class, result::class) + } + + @Test + fun checkFirstSuccessParser() { + // Test the FirstSuccessParser by attempting to apply two + // parsers to a test screen. We expect the first one + // (the IntegerParser) to fail, since the first token + // in the screen is not an integer. We expect the second + // one (the SequenceParser) to succeed, and the overall + // FirstSuccessParser to return that subparser's result. + + val testContext = TestContext(testFrameMainScreenWithTimeSeparator, 0) + val result = FirstSuccessParser( + listOf( + IntegerParser(), + SequenceParser( + listOf( + SingleGlyphParser(Glyph.SmallSymbol(SmallSymbol.CLOCK)), + TimeParser(), + SingleGlyphTypeParser(Glyph.LargeSymbol::class), + DecimalParser() + ) + ) + ) + ).parse(testContext.parseContext) + + assertEquals(ParseResult.Sequence::class, result::class) + val sequence = result as ParseResult.Sequence + assertEquals(3, sequence.values.size) + assertEquals(timeWithoutDate(hour = 10, minute = 20), sequence.valueAt(0)) + assertEquals(Glyph.LargeSymbol(LargeSymbol.BASAL), sequence.valueAt(1)) + assertEquals(200, sequence.valueAt(2)) + } + + @Test + fun checkSequenceParser() { + val testContext = TestContext(testTimeAndDateSettingsHour24hFormatScreen, 0) + val result = SequenceParser( + listOf( + StringParser(), + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.CLOCK)), + IntegerParser(IntegerParser.Mode.LARGE_DIGITS_ONLY), + TimeParser() + ) + ).parse(testContext.parseContext) + + assertEquals(ParseResult.Sequence::class, result::class) + val sequence = result as ParseResult.Sequence + assertEquals(3, sequence.values.size) + assertEquals("STUNDE", result.valueAt(0)) + assertEquals(10, result.valueAt(1)) + assertEquals(timeWithoutDate(hour = 10, minute = 22), result.valueAt(2)) + } + + @Test + fun checkSequenceWithOptionalAndNonMatchingParser() { + // This test combines SequenceParser and OptionalParser. + // We expect the OptionalParser's subparser to fail, and + // the SequenceParser's resulting Sequence to contain a + // Null value at the position of the OptionalParser. + + val testContext = TestContext(testTimeAndDateSettingsHour24hFormatScreen, 0) + val result = SequenceParser( + listOf( + StringParser(), + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.CLOCK)), + IntegerParser(IntegerParser.Mode.LARGE_DIGITS_ONLY), + OptionalParser(StringParser()), + TimeParser() + ) + ).parse(testContext.parseContext) + + assertEquals(ParseResult.Sequence::class, result::class) + val sequence = result as ParseResult.Sequence + assertEquals(4, sequence.values.size) + assertEquals("STUNDE", sequence.valueAt(0)) + assertEquals(10, sequence.valueAt(1)) + assertEquals(null, result.valueAtOrNull(2)) + assertEquals(timeWithoutDate(hour = 10, minute = 22), sequence.valueAt(3)) + } + + @Test + fun checkSequenceWithOptionalAndMatchingParser() { + // This test combines SequenceParser and OptionalParser. + // We expect the OptionalParser's subparser to succeed, + // and the SequenceParser's resulting Sequence to contain + // a string value at the position of the OptionalParser. + + val testContext = TestContext(testTimeAndDateSettingsHour12hFormatScreen, 0) + val result = SequenceParser( + listOf( + StringParser(), + SingleGlyphParser(Glyph.LargeSymbol(LargeSymbol.CLOCK)), + IntegerParser(IntegerParser.Mode.LARGE_DIGITS_ONLY), + OptionalParser(StringParser()), + TimeParser() + ) + ).parse(testContext.parseContext) + + assertEquals(ParseResult.Sequence::class, result::class) + val sequence = result as ParseResult.Sequence + assertEquals(4, sequence.values.size) + assertEquals("HOUR", sequence.valueAt(0)) + assertEquals(8, sequence.valueAt(1)) + assertEquals("PM", sequence.valueAtOrNull(2)) + assertEquals(timeWithoutDate(hour = 20, minute = 34), sequence.valueAt(3)) + } + + // Tests for screen parsing + + // The MainScreen tests start at token #1, since the first token in + // the main screen is the SMALL_CLOCK symbol. We do not test for + // that here, because during parsing, that symbol is expected to + // already have been parsed. + + @Test + fun checkNormalMainScreenWithTimeSeparatorParsing() { + val testContext = TestContext(testFrameMainScreenWithTimeSeparator, 1, parseTopLeftTime = true) + val result = NormalMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Normal( + currentTime = testContext.parseContext.topLeftTime!!, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 200, + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkNormalMainScreenWithoutTimeSeparatorParsing() { + val testContext = TestContext(testFrameMainScreenWithoutTimeSeparator, 1, parseTopLeftTime = true) + val result = NormalMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Normal( + currentTime = testContext.parseContext.topLeftTime!!, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 200, + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkTbrMainScreenParsing() { + val testContext = TestContext(testFrameMainScreenWithTbrInfo, 1, parseTopLeftTime = true) + val result = TbrMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Tbr( + currentTime = testContext.parseContext.topLeftTime!!, + remainingTbrDurationInMinutes = 30, + tbrPercentage = 110, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 220, + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkTbr90MainScreenParsing() { + // Variant of checkTbrMainScreenParsing with a 90% TBR. + // This is relevant, since at a TBR < 100%, the screen + // includes a DOWN symbol instead of an UP one next + // to the basal rate icon. + val testContext = TestContext(testFrameMainScreenWith90TbrInfo, 1, parseTopLeftTime = true) + val result = TbrMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Tbr( + currentTime = testContext.parseContext.topLeftTime!!, + remainingTbrDurationInMinutes = 5, + tbrPercentage = 90, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 45, + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkStoppedMainScreenWithTimeSeparatorParsing() { + val testContext = TestContext(testFrameMainScreenStoppedWithTimeSeparator, 1, parseTopLeftTime = true) + val result = StoppedMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Stopped( + currentDateTime = LocalDateTime( + year = 0, + monthNumber = 4, + dayOfMonth = 21, + hour = testContext.parseContext.topLeftTime!!.hour, + minute = testContext.parseContext.topLeftTime!!.minute, + second = 0 + ), + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkStoppedMainScreenWithoutTimeSeparatorParsing() { + val testContext = TestContext(testFrameMainScreenStoppedWithoutTimeSeparator, 1, parseTopLeftTime = true) + val result = StoppedMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Stopped( + currentDateTime = LocalDateTime( + year = 0, + monthNumber = 4, + dayOfMonth = 21, + hour = testContext.parseContext.topLeftTime!!.hour, + minute = testContext.parseContext.topLeftTime!!.minute, + second = 0 + ), + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkNormalMainScreenWithNoBatteryParsing() { + val testContext = TestContext(testFrameMainScreenWithNoBattery, 1, parseTopLeftTime = true) + val result = StoppedMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Stopped( + currentDateTime = LocalDateTime( + year = 0, + monthNumber = 2, + dayOfMonth = 1, + hour = testContext.parseContext.topLeftTime!!.hour, + minute = testContext.parseContext.topLeftTime!!.minute, + second = 0 + ), + batteryState = BatteryState.NO_BATTERY + ), + screen.content + ) + } + + @Test + fun checkNormalMainScreenWithLowBatteryParsing() { + val testContext = TestContext(testFrameMainScreenWithLowBattery, 1, parseTopLeftTime = true) + val result = NormalMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Normal( + currentTime = testContext.parseContext.topLeftTime!!, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 80, + batteryState = BatteryState.LOW_BATTERY + ), + screen.content + ) + } + + @Test + fun checkStoppedMainScreenWithLowBatteryParsing() { + val testContext = TestContext(testFrameMainScreenStoppedWithLowBattery, 1, parseTopLeftTime = true) + val result = StoppedMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Stopped( + currentDateTime = LocalDateTime( + year = 0, + monthNumber = 1, + dayOfMonth = 1, + hour = testContext.parseContext.topLeftTime!!.hour, + minute = testContext.parseContext.topLeftTime!!.minute, + second = 0 + ), + batteryState = BatteryState.LOW_BATTERY + ), + screen.content + ) + } + + @Test + fun checkTbr90MainScreenWithLowBatteryParsing() { + val testContext = TestContext(testFrameMainScreenWith90TbrInfoAndLowBattery, 1, parseTopLeftTime = true) + val result = TbrMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.Tbr( + currentTime = testContext.parseContext.topLeftTime!!, + remainingTbrDurationInMinutes = 15, + tbrPercentage = 90, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 72, + batteryState = BatteryState.LOW_BATTERY + ), + screen.content + ) + } + + @Test + fun checkExtendedBolusMainScreenParsing() { + val testContext = TestContext(testFrameMainScreenWithExtendedBolusInfo, 1, parseTopLeftTime = true) + val result = ExtendedAndMultiwaveBolusMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.ExtendedOrMultiwaveBolus( + currentTime = testContext.parseContext.topLeftTime!!, + remainingBolusDurationInMinutes = 3 * 60 + 0, + isExtendedBolus = true, + remainingBolusAmount = 2300, + tbrIsActive = false, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 790, + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkExtendedBolusWithTbrMainScreenParsing() { + val testContext = TestContext(testFrameMainScreenWithExtendedBolusInfoAndTbr, 1, parseTopLeftTime = true) + val result = ExtendedAndMultiwaveBolusMainScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.ExtendedOrMultiwaveBolus( + currentTime = testContext.parseContext.topLeftTime!!, + remainingBolusDurationInMinutes = 1 * 60 + 31, + isExtendedBolus = true, + remainingBolusAmount = 1300, + tbrIsActive = true, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 384, + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkMultiwaveBolusMainScreenParsing() { + val testContext = TestContext(testFrameMainScreenWithMultiwaveBolusInfo, 1, parseTopLeftTime = true) + val result = ExtendedAndMultiwaveBolusMainScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.MainScreen + assertEquals( + MainScreenContent.ExtendedOrMultiwaveBolus( + currentTime = testContext.parseContext.topLeftTime!!, + remainingBolusDurationInMinutes = 1 * 60 + 30, + isExtendedBolus = false, + remainingBolusAmount = 1700, + tbrIsActive = false, + activeBasalProfileNumber = 1, + currentBasalRateFactor = 790, + batteryState = BatteryState.FULL_BATTERY + ), + screen.content + ) + } + + @Test + fun checkMenuScreenParsing() { + val testScreens = listOf( + Pair(testFrameStandardBolusMenuScreen, ParsedScreen.StandardBolusMenuScreen), + Pair(testFrameExtendedBolusMenuScreen, ParsedScreen.ExtendedBolusMenuScreen), + Pair(testFrameMultiwaveBolusMenuScreen, ParsedScreen.MultiwaveBolusMenuScreen), + Pair(testFrameBluetoothSettingsMenuScreen, ParsedScreen.BluetoothSettingsMenuScreen), + Pair(testFrameMenuSettingsMenuScreen, ParsedScreen.MenuSettingsMenuScreen), + Pair(testFrameMyDataMenuScreen, ParsedScreen.MyDataMenuScreen), + Pair(testFrameBasalRateProfileSelectionMenuScreen, ParsedScreen.BasalRateProfileSelectionMenuScreen), + Pair(testFramePumpSettingsMenuScreen, ParsedScreen.PumpSettingsMenuScreen), + Pair(testFrameReminderSettingsMenuScreen, ParsedScreen.ReminderSettingsMenuScreen), + Pair(testFrameTimeAndDateSettingsMenuScreen, ParsedScreen.TimeAndDateSettingsMenuScreen), + Pair(testFrameStopPumpMenuScreen, ParsedScreen.StopPumpMenuScreen), + Pair(testFrameTemporaryBasalRateMenuScreen, ParsedScreen.TemporaryBasalRateMenuScreen), + Pair(testFrameTherapySettingsMenuScreen, ParsedScreen.TherapySettingsMenuScreen), + Pair(testFrameProgramBasalRate1MenuScreen, ParsedScreen.BasalRate1ProgrammingMenuScreen), + Pair(testFrameProgramBasalRate2MenuScreen, ParsedScreen.BasalRate2ProgrammingMenuScreen), + Pair(testFrameProgramBasalRate3MenuScreen, ParsedScreen.BasalRate3ProgrammingMenuScreen), + Pair(testFrameProgramBasalRate4MenuScreen, ParsedScreen.BasalRate4ProgrammingMenuScreen), + Pair(testFrameProgramBasalRate5MenuScreen, ParsedScreen.BasalRate5ProgrammingMenuScreen) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0) + val result = MenuScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen + assertEquals(testScreen.second, screen) + } + } + + @Test + fun checkBasalRateTotalScreenParsing() { + val testScreens = listOf( + Pair(testFrameBasalRateTotalScreen0, ParsedScreen.BasalRateTotalScreen(5160, 1)), + Pair(testFrameBasalRateTotalScreen1, ParsedScreen.BasalRateTotalScreen(56970, 2)) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0, skipTitleString = true) + val result = BasalRateTotalScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.BasalRateTotalScreen + assertEquals(testScreen.second, screen) + } + } + + @Test + fun checkBasalRateFactorSettingsScreenParsing() { + val testScreens = listOf( + Pair( + testFrameBasalRateFactorSettingNoFactorScreen, + ParsedScreen.BasalRateFactorSettingScreen( + timeWithoutDate(hour = 2, minute = 0), + timeWithoutDate(hour = 3, minute = 0), + null, 1 + ) + ), + Pair( + testFrameBasalRateFactorSettingScreen0, + ParsedScreen.BasalRateFactorSettingScreen( + timeWithoutDate(hour = 2, minute = 0), + timeWithoutDate(hour = 3, minute = 0), + 120, 1 + ) + ), + Pair( + testFrameBasalRateFactorSettingScreen1, + ParsedScreen.BasalRateFactorSettingScreen( + timeWithoutDate(hour = 2, minute = 0), + timeWithoutDate(hour = 3, minute = 0), + 10000, 2 + ) + ), + Pair( + testFrameBasalRateFactorSettingScreenAM, + ParsedScreen.BasalRateFactorSettingScreen( + timeWithoutDate(hour = 0, minute = 0), + timeWithoutDate(hour = 1, minute = 0), + 50, 1 + ) + ), + Pair( + testFrameBasalRateFactorSettingScreenAMPM, + ParsedScreen.BasalRateFactorSettingScreen( + timeWithoutDate(hour = 11, minute = 0), + timeWithoutDate(hour = 12, minute = 0), + 0, 3 + ) + ), + Pair( + testFrameBasalRateFactorSettingScreenPMAM, + ParsedScreen.BasalRateFactorSettingScreen( + timeWithoutDate(hour = 23, minute = 0), + timeWithoutDate(hour = 0, minute = 0), + 0, 3 + ) + ), + // This tests a special case where the basal rate factor setting screen + // shows a time period of 23:00 - 24:00 instead of 23:00 - 00:00. + // The "24:00" needs to be parsed correctly - as 00:00. + // If this is not done, then an hour value of 24 may be passed + // on to kotlinx.datetime LocalDateTime, which does _not_ accept + // HourOfDay values outside of the 0..23 range. + Pair( + testFrameBasalRateFactorSettingScreenMidnightAs24, + ParsedScreen.BasalRateFactorSettingScreen( + timeWithoutDate(hour = 23, minute = 0), + timeWithoutDate(hour = 0, minute = 0), + 800, 1 + ) + ) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 1, parseTopLeftTime = true) + val result = BasalRateFactorSettingScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.BasalRateFactorSettingScreen + assertEquals(testScreen.second.numUnits == null, screen.isBlinkedOut) + assertEquals(testScreen.second, screen) + } + } + + @Test + fun checkQuickinfoScreenParsing() { + val testContext = TestContext(testFrameQuickinfoMainScreen, 0, skipTitleString = true) + val result = QuickinfoScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen + assertEquals( + ParsedScreen.QuickinfoMainScreen( + Quickinfo(availableUnits = 213, reservoirState = ReservoirState.FULL) + ), + screen + ) + } + + @Test + fun checkW6CancelTbrWarningScreenParsing() { + val testContext = TestContext(testFrameW6CancelTbrWarningScreen, 0, skipTitleString = true) + val result = AlertScreenParser().parse(testContext.parseContext) + + assertEquals(ParseResult.Value::class, result::class) + val alertScreen = (result as ParseResult.Value<*>).value as ParsedScreen.AlertScreen + assertEquals(AlertScreenContent.Warning(6), alertScreen.content) + } + + @Test + fun checkW8CancelBolusWarningScreenParsing() { + val testScreens = listOf( + Pair(testFrameW8CancelBolusWarningScreen0, AlertScreenContent.None), + Pair(testFrameW8CancelBolusWarningScreen1, AlertScreenContent.Warning(8)), + Pair(testFrameW8CancelBolusWarningScreen2, AlertScreenContent.None), + Pair(testFrameW8CancelBolusWarningScreen3, AlertScreenContent.Warning(8)) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0, skipTitleString = true) + val result = AlertScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.AlertScreen + assertEquals(testScreen.second, screen.content) + } + } + + @Test + fun checkE2BatteryEmptyErrorScreenParsing() { + val testScreens = listOf( + Pair(testFrameE2BatteryEmptyErrorScreen0, AlertScreenContent.None), + Pair(testFrameE2BatteryEmptyErrorScreen1, AlertScreenContent.Error(2)) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0, skipTitleString = true) + val result = AlertScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.AlertScreen + assertEquals(testScreen.second, screen.content) + } + } + + @Test + fun checkTemporaryBasalRatePercentageScreenParsing() { + val testScreens = listOf( + Pair(testFrameTemporaryBasalRatePercentage100Screen, 100), + Pair(testFrameTemporaryBasalRatePercentage110Screen, 110), + Pair(testFrameTemporaryBasalRateNoPercentageScreen, null), + Pair(testFrameTbrPercentageEnglishScreen, 110), + Pair(testFrameTbrPercentageSpanishScreen, 110), + Pair(testFrameTbrPercentageFrenchScreen, 110), + Pair(testFrameTbrPercentageItalianScreen, 110), + Pair(testFrameTbrPercentageRussianScreen, 110), + Pair(testFrameTbrPercentageTurkishScreen, 110), + Pair(testFrameTbrPercentagePolishScreen, 100), + Pair(testFrameTbrPercentageCzechScreen, 110), + Pair(testFrameTbrPercentageHungarianScreen, 110), + Pair(testFrameTbrPercentageSlovakScreen, 110), + Pair(testFrameTbrPercentageRomanianScreen, 110), + Pair(testFrameTbrPercentageCroatianScreen, 110), + Pair(testFrameTbrPercentageDutchScreen, 110), + Pair(testFrameTbrPercentageGreekScreen, 110), + Pair(testFrameTbrPercentageFinnishScreen, 110), + Pair(testFrameTbrPercentageNorwegianScreen, 110), + Pair(testFrameTbrPercentagePortugueseScreen, 110), + Pair(testFrameTbrPercentageSwedishScreen, 110), + Pair(testFrameTbrPercentageDanishScreen, 110), + Pair(testFrameTbrPercentageGermanScreen, 110) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0, skipTitleString = true) + val result = TemporaryBasalRatePercentageScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.TemporaryBasalRatePercentageScreen + assertEquals(testScreen.second == null, screen.isBlinkedOut) + assertEquals(testScreen.second, screen.percentage) + } + } + + @Test + fun checkTemporaryBasalRatePercentageScreenPercentAndDurationParsing() { + val testContext = TestContext(testFrameTemporaryBasalRatePercentage110Screen, 0, skipTitleString = true) + val result = TemporaryBasalRatePercentageScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.TemporaryBasalRatePercentageScreen + assertEquals(false, screen.isBlinkedOut) + assertEquals(110, screen.percentage) + assertEquals(30, screen.remainingDurationInMinutes) + } + + @Test + fun checkTemporaryBasalRateDurationScreenParsing() { + val testScreens = listOf( + Pair(testFrameTbrDurationNoDurationScreen, null), + Pair(testFrameTbrDurationEnglishScreen, 30), + Pair(testFrameTbrDurationSpanishScreen, 30), + Pair(testFrameTbrDurationFrenchScreen, 30), + Pair(testFrameTbrDurationItalianScreen, 30), + Pair(testFrameTbrDurationRussianScreen, 30), + Pair(testFrameTbrDurationTurkishScreen, 30), + Pair(testFrameTbrDurationPolishScreen, 30), + Pair(testFrameTbrDurationCzechScreen, 30), + Pair(testFrameTbrDurationHungarianScreen, 30), + Pair(testFrameTbrDurationSlovakScreen, 30), + Pair(testFrameTbrDurationRomanianScreen, 30), + Pair(testFrameTbrDurationCroatianScreen, 30), + Pair(testFrameTbrDurationDutchScreen, 30), + Pair(testFrameTbrDurationGreekScreen, 30), + Pair(testFrameTbrDurationFinnishScreen, 30), + Pair(testFrameTbrDurationNorwegianScreen, 30), + Pair(testFrameTbrDurationPortugueseScreen, 30), + Pair(testFrameTbrDurationSwedishScreen, 30), + Pair(testFrameTbrDurationDanishScreen, 30), + Pair(testFrameTbrDurationGermanScreen, 30) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0, skipTitleString = true) + val result = TemporaryBasalRateDurationScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen.TemporaryBasalRateDurationScreen + assertEquals(testScreen.second == null, screen.isBlinkedOut) + assertEquals(testScreen.second, screen.durationInMinutes) + } + } + + @Test + fun checkTimeAndDateSettingsScreenParsing() { + val testScreens = listOf( + Pair(testTimeAndDateSettingsHour12hFormatScreen, ParsedScreen.TimeAndDateSettingsHourScreen(20)), + Pair(testTimeAndDateSettingsHour24hFormatScreen, ParsedScreen.TimeAndDateSettingsHourScreen(10)), + + Pair(testTimeAndDateSettingsHourEnglishScreen, ParsedScreen.TimeAndDateSettingsHourScreen(8)), + Pair(testTimeAndDateSettingsMinuteEnglishScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(35)), + Pair(testTimeAndDateSettingsYearEnglishScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthEnglishScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayEnglishScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourSpanishScreen, ParsedScreen.TimeAndDateSettingsHourScreen(8)), + Pair(testTimeAndDateSettingsMinuteSpanishScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(36)), + Pair(testTimeAndDateSettingsYearSpanishScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthSpanishScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDaySpanishScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourFrenchScreen, ParsedScreen.TimeAndDateSettingsHourScreen(10)), + Pair(testTimeAndDateSettingsMinuteFrenchScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(4)), + Pair(testTimeAndDateSettingsYearFrenchScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthFrenchScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayFrenchScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourItalianScreen, ParsedScreen.TimeAndDateSettingsHourScreen(13)), + Pair(testTimeAndDateSettingsMinuteItalianScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(48)), + Pair(testTimeAndDateSettingsYearItalianScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthItalianScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayItalianScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourRussianScreen, ParsedScreen.TimeAndDateSettingsHourScreen(13)), + Pair(testTimeAndDateSettingsMinuteRussianScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(52)), + Pair(testTimeAndDateSettingsYearRussianScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthRussianScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayRussianScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourTurkishScreen, ParsedScreen.TimeAndDateSettingsHourScreen(13)), + Pair(testTimeAndDateSettingsMinuteTurkishScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(53)), + Pair(testTimeAndDateSettingsYearTurkishScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthTurkishScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayTurkishScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourPolishScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinutePolishScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearPolishScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthPolishScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayPolishScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourCzechScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteCzechScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearCzechScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthCzechScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayCzechScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourHungarianScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteHungarianScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearHungarianScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthHungarianScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayHungarianScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourSlovakScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteSlovakScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearSlovakScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthSlovakScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDaySlovakScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourRomanianScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteRomanianScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearRomanianScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthRomanianScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayRomanianScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourCroatianScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteCroatianScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearCroatianScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthCroatianScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayCroatianScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourDutchScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteDutchScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearDutchScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthDutchScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayDutchScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourGreekScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteGreekScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearGreekScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthGreekScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayGreekScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourFinnishScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteFinnishScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearFinnishScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthFinnishScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayFinnishScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourNorwegianScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteNorwegianScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearNorwegianScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthNorwegianScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayNorwegianScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourPortugueseScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinutePortugueseScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearPortugueseScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthPortugueseScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayPortugueseScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourSwedishScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteSwedishScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearSwedishScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthSwedishScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDaySwedishScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourDanishScreen, ParsedScreen.TimeAndDateSettingsHourScreen(14)), + Pair(testTimeAndDateSettingsMinuteDanishScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(34)), + Pair(testTimeAndDateSettingsYearDanishScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthDanishScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayDanishScreen, ParsedScreen.TimeAndDateSettingsDayScreen(27)), + + Pair(testTimeAndDateSettingsHourGermanScreen, ParsedScreen.TimeAndDateSettingsHourScreen(10)), + Pair(testTimeAndDateSettingsMinuteGermanScreen, ParsedScreen.TimeAndDateSettingsMinuteScreen(22)), + Pair(testTimeAndDateSettingsYearGermanScreen, ParsedScreen.TimeAndDateSettingsYearScreen(2015)), + Pair(testTimeAndDateSettingsMonthGermanScreen, ParsedScreen.TimeAndDateSettingsMonthScreen(4)), + Pair(testTimeAndDateSettingsDayGermanScreen, ParsedScreen.TimeAndDateSettingsDayScreen(21)), + + // Extra test to verify that a *minute* 24 is not incorrectly interpreted + // as an *hour* 24 and thus translated to 0 (this is done because the Combo + // may show midnight as both hour 0 and hour 24). + Pair(testTimeAndDateSettingsMinute24Screen, ParsedScreen.TimeAndDateSettingsMinuteScreen(24)) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0) + + val titleString = (StringParser().parse(testContext.parseContext) as ParseResult.Value<*>).value as String + val titleId = knownScreenTitles[titleString] + assertNotNull(titleId) + + val result = TimeAndDateSettingsScreenParser(titleId).parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen + assertEquals(testScreen.second, screen) + } + } + + @Test + fun checkMyDataScreenParsing() { + val testScreens = listOf( + Pair( + testMyDataBolusDataEnglishScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 8, hour = 9, minute = 57, second = 0), + bolusAmount = 1000, bolusType = MyDataBolusType.STANDARD, durationInMinutes = null + ) + ), + Pair( + testMyDataErrorDataEnglishScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 28, hour = 11, minute = 0, second = 0), + alert = AlertScreenContent.Warning(6) + ) + ), + Pair( + testMyDataDailyTotalsEnglishScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 30, monthNumber = 1), + totalDailyAmount = 26900 + ) + ), + Pair( + testMyDataTbrDataEnglishScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 28, hour = 11, minute = 0, second = 0), + percentage = 110, durationInMinutes = 0 + ) + ), + Pair( + testMyDataBolusDataSpanishScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 8, hour = 9, minute = 57, second = 0), + bolusAmount = 1000, bolusType = MyDataBolusType.STANDARD, durationInMinutes = null + ) + ), + Pair( + testMyDataErrorDataSpanishScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 28, hour = 11, minute = 0, second = 0), + alert = AlertScreenContent.Warning(6) + ) + ), + Pair( + testMyDataDailyTotalsSpanishScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 30, monthNumber = 1), + totalDailyAmount = 26900 + ) + ), + Pair( + testMyDataTbrDataSpanishScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 28, hour = 11, minute = 0, second = 0), + percentage = 110, durationInMinutes = 0 + ) + ), + Pair( + testMyDataBolusDataFrenchScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 10, hour = 15, minute = 21, second = 0), + bolusAmount = 4000, bolusType = MyDataBolusType.EXTENDED, durationInMinutes = 5 + ) + ), + Pair( + testMyDataErrorDataFrenchScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsFrenchScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 7600 + ) + ), + Pair( + testMyDataTbrDataFrenchScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataItalianScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataItalianScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsItalianScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 11200 + ) + ), + Pair( + testMyDataTbrDataItalianScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataRussianScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataRussianScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsRussianScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 12900 + ) + ), + Pair( + testMyDataTbrDataRussianScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataTurkishScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataTurkishScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsTurkishScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 12900 + ) + ), + Pair( + testMyDataTbrDataTurkishScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataPolishScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataPolishScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsPolishScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 12900 + ) + ), + Pair( + testMyDataTbrDataPolishScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataCzechScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataCzechScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsCzechScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 13900 + ) + ), + Pair( + testMyDataTbrDataCzechScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataHungarianScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataHungarianScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsHungarianScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 13900 + ) + ), + Pair( + testMyDataTbrDataHungarianScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataSlovakScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataSlovakScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsSlovakScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 13900 + ) + ), + Pair( + testMyDataTbrDataSlovakScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataRomanianScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataRomanianScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + alert = AlertScreenContent.Warning(7) + ) + ), + Pair( + testMyDataDailyTotalsRomanianScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 12, monthNumber = 5), + totalDailyAmount = 13900 + ) + ), + Pair( + testMyDataTbrDataRomanianScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 11, hour = 21, minute = 56, second = 0), + percentage = 110, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataCroatianScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataCroatianScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 2, dayOfMonth = 1, hour = 1, minute = 6, second = 0), + alert = AlertScreenContent.Warning(1) + ) + ), + Pair( + testMyDataDailyTotalsCroatianScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 10, monthNumber = 2), + totalDailyAmount = 5800 + ) + ), + Pair( + testMyDataTbrDataCroatianScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 6, dayOfMonth = 11, hour = 17, minute = 25, second = 0), + percentage = 240, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataDutchScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataDutchScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 2, dayOfMonth = 1, hour = 1, minute = 6, second = 0), + alert = AlertScreenContent.Warning(1) + ) + ), + Pair( + testMyDataDailyTotalsDutchScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 10, monthNumber = 2), + totalDailyAmount = 5800 + ) + ), + Pair( + testMyDataTbrDataDutchScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 6, dayOfMonth = 11, hour = 17, minute = 25, second = 0), + percentage = 240, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataGreekScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataGreekScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 2, dayOfMonth = 1, hour = 1, minute = 6, second = 0), + alert = AlertScreenContent.Warning(1) + ) + ), + Pair( + testMyDataDailyTotalsGreekScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 10, monthNumber = 2), + totalDailyAmount = 5800 + ) + ), + Pair( + testMyDataTbrDataGreekScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 6, dayOfMonth = 11, hour = 17, minute = 25, second = 0), + percentage = 240, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataFinnishScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataFinnishScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 2, dayOfMonth = 1, hour = 1, minute = 6, second = 0), + alert = AlertScreenContent.Warning(1) + ) + ), + Pair( + testMyDataDailyTotalsFinnishScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 10, monthNumber = 2), + totalDailyAmount = 5900 + ) + ), + Pair( + testMyDataTbrDataFinnishScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 6, dayOfMonth = 11, hour = 17, minute = 25, second = 0), + percentage = 240, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataNorwegianScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataNorwegianScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 2, dayOfMonth = 1, hour = 1, minute = 6, second = 0), + alert = AlertScreenContent.Warning(1) + ) + ), + Pair( + testMyDataDailyTotalsNorwegianScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 10, monthNumber = 2), + totalDailyAmount = 5900 + ) + ), + Pair( + testMyDataTbrDataNorwegianScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 6, dayOfMonth = 11, hour = 17, minute = 25, second = 0), + percentage = 240, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataPortugueseScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataPortugueseScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 2, dayOfMonth = 1, hour = 1, minute = 6, second = 0), + alert = AlertScreenContent.Warning(1) + ) + ), + Pair( + testMyDataDailyTotalsPortugueseScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 10, monthNumber = 2), + totalDailyAmount = 5900 + ) + ), + Pair( + testMyDataTbrDataPortugueseScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 6, dayOfMonth = 11, hour = 17, minute = 25, second = 0), + percentage = 240, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataSwedishScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataSwedishScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 2, dayOfMonth = 1, hour = 1, minute = 6, second = 0), + alert = AlertScreenContent.Warning(1) + ) + ), + Pair( + testMyDataDailyTotalsSwedishScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 10, monthNumber = 2), + totalDailyAmount = 5900 + ) + ), + Pair( + testMyDataTbrDataSwedishScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 6, dayOfMonth = 11, hour = 17, minute = 25, second = 0), + percentage = 240, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataDanishScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 5, dayOfMonth = 12, hour = 16, minute = 30, second = 0), + bolusAmount = 2700, bolusType = MyDataBolusType.MULTI_WAVE, durationInMinutes = 13 + ) + ), + Pair( + testMyDataErrorDataDanishScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 2, dayOfMonth = 1, hour = 1, minute = 6, second = 0), + alert = AlertScreenContent.Warning(1) + ) + ), + Pair( + testMyDataDailyTotalsDanishScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 10, monthNumber = 2), + totalDailyAmount = 5900 + ) + ), + Pair( + testMyDataTbrDataDanishScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 6, dayOfMonth = 11, hour = 17, minute = 25, second = 0), + percentage = 240, durationInMinutes = 60 + ) + ), + Pair( + testMyDataBolusDataGermanScreen, + ParsedScreen.MyDataBolusDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 8, hour = 9, minute = 57, second = 0), + bolusAmount = 1000, bolusType = MyDataBolusType.STANDARD, durationInMinutes = null + ) + ), + Pair( + testMyDataErrorDataGermanScreen, + ParsedScreen.MyDataErrorDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 28, hour = 11, minute = 0, second = 0), + alert = AlertScreenContent.Warning(6) + ) + ), + Pair( + testMyDataDailyTotalsGermanScreen, + ParsedScreen.MyDataDailyTotalsScreen( + index = 1, totalNumEntries = 30, date = LocalDate(year = 0, dayOfMonth = 30, monthNumber = 1), + totalDailyAmount = 26900 + ) + ), + Pair( + testMyDataTbrDataGermanScreen, + ParsedScreen.MyDataTbrDataScreen( + index = 1, totalNumEntries = 30, + timestamp = LocalDateTime(year = 0, monthNumber = 1, dayOfMonth = 28, hour = 11, minute = 0, second = 0), + percentage = 110, durationInMinutes = 0 + ) + ) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0) + + val titleString = (StringParser().parse(testContext.parseContext) as ParseResult.Value<*>).value as String + val titleId = knownScreenTitles[titleString] + assertNotNull(titleId) + + val result: ParseResult = when (titleId) { + TitleID.BOLUS_DATA -> MyDataBolusDataScreenParser().parse(testContext.parseContext) + TitleID.ERROR_DATA -> MyDataErrorDataScreenParser().parse(testContext.parseContext) + TitleID.DAILY_TOTALS -> MyDataDailyTotalsScreenParser().parse(testContext.parseContext) + TitleID.TBR_DATA -> MyDataTbrDataScreenParser().parse(testContext.parseContext) + else -> { + fail("Unknown title string \"$titleString\"") + } + } + + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen + assertEquals(testScreen.second, screen) + } + } + + @Test + fun checkToplevelScreenParsing() { + val testScreens = listOf( + Pair(testFrameStandardBolusMenuScreen, ParsedScreen.StandardBolusMenuScreen), + Pair(testFrameExtendedBolusMenuScreen, ParsedScreen.ExtendedBolusMenuScreen), + Pair(testFrameMultiwaveBolusMenuScreen, ParsedScreen.MultiwaveBolusMenuScreen), + Pair(testFrameBluetoothSettingsMenuScreen, ParsedScreen.BluetoothSettingsMenuScreen), + Pair(testFrameMenuSettingsMenuScreen, ParsedScreen.MenuSettingsMenuScreen), + Pair(testFrameMyDataMenuScreen, ParsedScreen.MyDataMenuScreen), + Pair(testFrameBasalRateProfileSelectionMenuScreen, ParsedScreen.BasalRateProfileSelectionMenuScreen), + Pair(testFramePumpSettingsMenuScreen, ParsedScreen.PumpSettingsMenuScreen), + Pair(testFrameReminderSettingsMenuScreen, ParsedScreen.ReminderSettingsMenuScreen), + Pair(testFrameTimeAndDateSettingsMenuScreen, ParsedScreen.TimeAndDateSettingsMenuScreen), + Pair(testFrameStopPumpMenuScreen, ParsedScreen.StopPumpMenuScreen), + Pair(testFrameTemporaryBasalRateMenuScreen, ParsedScreen.TemporaryBasalRateMenuScreen), + Pair(testFrameTherapySettingsMenuScreen, ParsedScreen.TherapySettingsMenuScreen), + Pair(testFrameProgramBasalRate1MenuScreen, ParsedScreen.BasalRate1ProgrammingMenuScreen), + Pair(testFrameProgramBasalRate2MenuScreen, ParsedScreen.BasalRate2ProgrammingMenuScreen), + Pair(testFrameProgramBasalRate3MenuScreen, ParsedScreen.BasalRate3ProgrammingMenuScreen), + Pair(testFrameProgramBasalRate4MenuScreen, ParsedScreen.BasalRate4ProgrammingMenuScreen), + Pair(testFrameProgramBasalRate5MenuScreen, ParsedScreen.BasalRate5ProgrammingMenuScreen) + ) + + for (testScreen in testScreens) { + val testContext = TestContext(testScreen.first, 0) + val result = ToplevelScreenParser().parse(testContext.parseContext) + assertEquals(ParseResult.Value::class, result::class) + val screen = (result as ParseResult.Value<*>).value as ParsedScreen + assertEquals(testScreen.second, screen) + } + } +} diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/TestDisplayFrames.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/TestDisplayFrames.kt new file mode 100644 index 0000000000..73eabc7362 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/TestDisplayFrames.kt @@ -0,0 +1,9714 @@ +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 info.nightscout.comboctl.base.NUM_DISPLAY_FRAME_PIXELS + +private fun makeDisplayFrame(templateRows: Array): DisplayFrame { + assert(templateRows.size == DISPLAY_FRAME_HEIGHT) + assert(templateRows[0].length == DISPLAY_FRAME_WIDTH) + + val pixels = BooleanArray(NUM_DISPLAY_FRAME_PIXELS) { false } + + for (y in 0 until DISPLAY_FRAME_HEIGHT) { + assert(templateRows[y].length == DISPLAY_FRAME_WIDTH) + for (x in 0 until DISPLAY_FRAME_WIDTH) + pixels[x + y * DISPLAY_FRAME_WIDTH] = (templateRows[y][x] != ' ') + } + + return DisplayFrame(pixels) +} + +val testFrameMainScreenWithTimeSeparator = makeDisplayFrame(arrayOf( + " ███ █ ███ ███ ███ ", + " █ █ █ ██ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ ██ ██ █ █ ██ ", + "█ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ██ █ ██ █ ██ █ ", + " █ █ █ █ █ ██ █ █ █ ", + " ███ ███ ███ █████ ███ ", + " ", + " ████ ████ ████ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ███ ████████ ████ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameMainScreenWithoutTimeSeparator = makeDisplayFrame(arrayOf( + " ███ █ ███ ███ ███ ", + " █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ ██ ", + "█ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ██ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ ", + " ", + " ████ ████ ████ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ███ ████████ ████ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameMainScreenWithTbrInfo = makeDisplayFrame(arrayOf( + " ███ █ ███ ███ █ █ ███ ███ █████ ███ ", + " █ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ ██ █ █ █", + "█ █ █ █ █ ██ ██ █ █ ███████ █ ██ █ ██ ██ █ █ ██", + "█ ██ █ █ █ █ █ █ █ ████████ █ █ █ █ █ █ █ █ █ █", + "█ █ █ ██ █ ██ █ █ ███████ ██ █ ██ █ ██ █ ██ █", + " █ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ █ █ █ █", + " ███ ███ ███ █████ ███ █ ███ ███ ███ ███ ", + " ", + " █ ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ███ ██ ██ ████ ██ ", + " ███████ █████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ███ ██ ██ ██ ██ ██ ", + " ██ ███████ ███ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ███ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ██ ██ ████ ", + " ", + " ", + " ███ ███ ███ ███ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "██ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ ██ █ █ █ █ █ █ █ █ █ ", + " ███ ██ █████ █████ ███ ██ █ █ █ " +)) + +val testFrameMainScreenWith90TbrInfo = makeDisplayFrame(arrayOf( + " ███ ███ █████ █ ██ █ █ █ █ ███ ███ ███ █████", + " █ █ █ █ █ █ ██ ██ █ █ █ ██ ██ ██ █ █ █ █ ██ █ █ █ ", + "█ █ █ █ ██ ████ ██ █ █ █ █ █ █ █ ███████ █ ██ █ ██ ██ █ ██ ████ ", + "█ ██ █ █ █ █ █ █ ████ █████ █ █ █ ████████ █ █ █ █ █ █ █ █ █ █", + "█ █ ██ █ █ ██ █ █ █ █ █ █ █ ███████ ██ █ ██ █ ██ ██ █ █", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ █ █ █", + " ███ ███ ███ ███ ███ █ █ █ █ █ ███ ███ ███ ███ ", + " ", + " ███ ████ ████ ██ ██ ", + " ███████ ███ ██ ██ ██ ██ ████ ██ ", + " ███████ ███ ██ ██ ██ ██ ████ ██ ", + " ██ ██ ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ █████ ██ ██ ██ ██ ██ ", + " ██ ███████ ███ ██ ██ ██ ██ ██ ", + "███████ ██ ██ █ ██ ███ ██ ██ ██ ", + "███████ ██ ██ ██████ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ███ ████ ", + " ", + " ", + " ███ ███ █ █████ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ ██ █ ██ █ █ ████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "██ █ ██ █ █████ █ █ █ █ █ █ ", + "█ █ ██ █ █ █ █ █ █ █ █ █ █ ", + " ███ ██ ███ █ ███ ██ █ █ █ " +)) + +val testFrameMainScreenWithExtendedBolusInfo = makeDisplayFrame(arrayOf( + " ███ ███ █ █████ ███ █ ███ █████ ███ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ ██ █ █ █ ██ █ █ █ █", + "█ █ █ █ █ ██ ████ █ █ ███████ █ ██ █ ██ █ ██ █ ██", + "█ ██ █ █ █ █ ███ ████████ █ █ █ █ █ █ █ █ █ █", + "█ █ █ █ ██ █ █ █ ███████ ██ █ █ ██ ██ █ ██ █", + " █ █ █ █ ██ █ █ █ █ ██ █ █ █ █ ██ █ █ █ █", + " ███ █████ ███ ███ ███ █ ███ ███ ███ ███ ", + " ", + " ████ █████ ", + "█████████████ ██ ██ ██ ██ ", + "█████████████ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ███ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ █ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ", + "██ ██ █ ██ ██ ██ ██ ", + "██ ██ █ ██ ██ ██ ██ ", + "██ ██ █ ██ ███ ██ ██ ██ ", + "██ █████ █ ██ ███ ██ ██ ██ ██ ", + "██ █████ ███ ████████ ███ █████ ████ ", + " ", + " ", + " ███ █████ ███ ███ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ ████ █ █ █ █ █ █ ██ █ ", + "██ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ ██ █ █ █ █ █ █ █ █ █ ", + " ███ ██ █ ██ ███ ██ █ █ █ " +)) + +val testFrameMainScreenWithExtendedBolusInfoAndTbr = makeDisplayFrame(arrayOf( + " ███ █ █████ █ █ █ ███ █ █████ █ ", + " █ █ █ ██ █ ██ ██ ██ █ █ ██ ██ █ ██ ", + "█ █ █ █ ████ █ █ █ ███████ █ ██ █ ██ █ █ ", + "█ ██ █ █ █ █ █ █ ████████ █ █ █ █ █ █ ", + "█ █ █ █ █████ █ ███████ ██ █ █ ██ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █ ", + " ███ ███ ███ █ ███ █ ███ ███ ███ ███ ", + " ", + " ███ ██ █████ ", + "█████████████ ███ ███ ██ ██ ", + "█████████████ ███ ████ ██ ", + "██ ██ ███████ ██ ██ ██ ██ ", + "██ ██ █████ ██ ██ ██ ██ ", + "██ ██ ███ ██ ██ ██ ██ ", + "██ ██ █ ██ ███ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ █ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ", + "██ ██ █ ██ ██ ██ ██ ", + "██ ██ █ ██ ██ ██ ██ ", + "██ ██ █ ██ ███ ██ ██ ██ ", + "██ █████ █ ██ ███ ██ ██ ██ ██ ", + "██ █████ ███ ██ ███ █████ ████ ", + " ", + " ", + " ███ █████ ███ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ███ █ █ █ █ █ ██ █ ", + "██ █ █ █ █ █████ █ █ █ █ █ ", + "█ █ ██ █ █ █ █ █ █ █ █ █ █ ", + " ███ ██ ███ ███ █ ██ █ █ █ " +)) + +val testFrameMainScreenWithMultiwaveBolusInfo = makeDisplayFrame(arrayOf( + " ███ ███ ███ ███ ███ █ ███ █ █████ ███ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ █ ██ ██ █ █ █", + "█ █ █ █ █ ██ █ ██ █ ██ ███████ █ ██ █ ██ █ █ ██", + "█ ██ █ █ █ █ █ █ █ █ █ ████████ █ █ █ █ █ █ █ █", + "█ █ █ █ ██ ██ █ ██ █ ███████ ██ █ █ ██ █ ██ █", + " █ █ █ █ ██ █ █ █ █ ██ █ █ █ ██ █ █ █ █", + " ███ █████ █████ ███ ███ █ ███ ███ ███ ███ ", + " ", + " ██ ████████ ", + "██████ ███ ██ ", + "██████ ████ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ████████████ ██ ██ ██ ██ ", + "██ ████████████ █ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ", + "██ ██ █ ██ ██ ██ ██ ", + "██ ██ █ ██ ██ ██ ██ ", + "██ ██ █ ██ ███ ██ ██ ██ ", + "██ ██ █ ██ ███ ██ ██ ██ ", + "██ ██ ███ ██ ███ ██ ████ ", + " ", + " ", + " ███ █████ ███ ███ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ ████ █ █ █ █ █ █ ██ █ ", + "██ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ ██ █ █ █ █ █ █ █ █ █ ", + " ███ ██ █ ██ ███ ██ █ █ █ " +)) + +val testFrameMainScreenStoppedWithTimeSeparator = makeDisplayFrame(arrayOf( + " ███ █ ███ ███ ███ ███████ ███ █ ███ █ ", + " █ █ █ ██ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ ██ ", + "█ █ █ █ █ ██ ██ █ █ ██ ███████ █ █ █ ██ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ██ █ ██ █ ██ █ ███████ █ █ ██ █ █████ ", + " █ █ █ █ █ ██ █ █ █ █ █ ███ █ █ ██ █ █ █ ", + " ███ ███ ███ █████ ███ ███████ █████ ███ ██ ███ █ ", + " ", + " ████████ ", + " ██████████ ", + " ████████████ ", + " ██████████████ ", + " ████████████████ ", + " █ █ █ █ ██ ", + " █ ███ ██ █ █ █ █ ", + " █ ██ ██ █ █ ██ ", + " ██ ██ ██ █ █ ███ ", + " █ ██ ██ █ ███ ", + " ████████████████ ", + " ██████████████ ", + " ████████████ ", + " ██████████ ", + " ████████ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameMainScreenStoppedWithoutTimeSeparator = makeDisplayFrame(arrayOf( + " ███ █ ███ ███ ███ ███████ ███ █ ███ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ ██ ", + "█ █ █ █ █ ██ █ █ ██ ███████ █ █ █ ██ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ██ █ █ ██ █ ███████ █ █ ██ █ █████ ", + " █ █ █ █ █ █ █ █ █ █ ███ █ █ ██ █ █ █ ", + " ███ ███ ███ █████ ███ ███████ █████ ███ ██ ███ █ ", + " ", + " ████████ ", + " ██████████ ", + " ████████████ ", + " ██████████████ ", + " ████████████████ ", + " █ █ █ █ ██ ", + " █ ███ ██ █ █ █ █ ", + " █ ██ ██ █ █ ██ ", + " ██ ██ ██ █ █ ███ ", + " █ ██ ██ █ ███ ", + " ████████████████ ", + " ██████████████ ", + " ████████████ ", + " ██████████ ", + " ████████ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameMainScreenWithNoBattery = makeDisplayFrame(arrayOf( + " ███ ███ █████ █ █████ ███████ ███ █ ███ ███ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ ██ ████ █ █ █ ███████ █ ██ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ █████ █ ███████ ██ █ █ ██ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ ██ █ █ █ ", + " ███ ███ ███ █ ███ ███████ ███ ███ ██ ███ █████ ", + " ", + " ████████ ", + " ██████████ ", + " ████████████ ", + " ██████████████ ", + " ████████████████ ", + " █ █ █ █ ██ ", + " █ ███ ██ █ █ █ █ ", + " █ ██ ██ █ █ ██ ", + " ██ ██ ██ █ █ ███ ", + " █ ██ ██ █ ███ ", + " ████████████████ ", + " ██████████████ ", + " ████████████ ", + " ██████████ ", + " ████████ ", + " ", + " ", + " ██████████ ", + " █ █ ", + " █ ██ ", + " █ █ ", + " █ ██ ", + " █ █ ", + " ██████████ " +)) + +val testFrameMainScreenWithLowBattery = makeDisplayFrame(arrayOf( + " ███ █ ███ ███ ███ █ █ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ █ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ ", + "█ █ █ █ ██ █ ██ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ███ █████ ███ ███ █ █ █ █ ", + " ", + " ████ ████ ████ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ███ ████ ████ ████ ██ ██ ██ ", + " ", + " ", + " ██████████ ", + " █ █ ", + " ███ ██ ", + " ███ █ ", + " ███ ██ ", + " █ █ ", + " ██████████ " +)) + +val testFrameMainScreenStoppedWithLowBattery = makeDisplayFrame(arrayOf( + " ███ █ ███ ███ ███ █ █ █ ███████ ███ █ ███ █ ", + " █ █ █ ██ █ █ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ ██ █ █ █ ██ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ ██ █ ██ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █████ ███ ███ █ █ █ █ ███████ ███ ███ ███ ███ ", + " ", + " ████████ ", + " ██████████ ", + " ████████████ ", + " ██████████████ ", + " ████████████████ ", + " █ █ █ █ ██ ", + " █ ███ ██ █ █ █ █ ", + " █ ██ ██ █ █ ██ ", + " ██ ██ ██ █ █ ███ ", + " █ ██ ██ █ ███ ", + " ████████████████ ", + " ██████████████ ", + " ████████████ ", + " ██████████ ", + " ████████ ", + " ", + " ", + " ██████████ ", + " █ █ ", + " ███ ██ ", + " ███ █ ", + " ███ ██ ", + " █ █ ", + " ██████████ " +)) + +val testFrameMainScreenWith90TbrInfoAndLowBattery = makeDisplayFrame(arrayOf( + " ███ █ ███ ███ ███ █ █ █ █ ███ ███ █ █████", + " █ █ █ ██ █ █ ██ █ █ █ █ █ █ ██ ██ ██ █ █ █ █ ██ ██ █ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ ███████ █ ██ █ ██ ██ █ ████ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ ████████ █ █ █ █ █ █ █ █", + "█ █ █ █ ██ ██ █ ██ █ █ █ █ █ ███████ ██ █ ██ █ ██ █ █", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ █ █", + " ███ ███ █████ ███ ███ █ █ █ █ █ ███ ███ ███ ███ ", + " ", + " ███ ████ ████ ██ ██ ", + " ███████ ███ ██ ██ ██ ██ ████ ██ ", + " ███████ ███ ██ ██ ██ ██ ████ ██ ", + " ██ ██ ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ █████ ██ ██ ██ ██ ██ ", + " ██ ███████ ███ ██ ██ ██ ██ ██ ", + "███████ ██ ██ █ ██ ███ ██ ██ ██ ", + "███████ ██ ██ ██████ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ███ ████ ", + " ", + " ", + " ███ ███ █████ ███ █ █ █ █ ██████████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ ███ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ ██ █ ███ █ ", + "██ █ ██ █ █ █ █ █ █ █ █ ███ ██ ", + "█ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ██ ███ █ █████ ██ █ █ █ ██████████ " +)) + +val testFrameStandardBolusMenuScreen = makeDisplayFrame(arrayOf( + " ████ █████ █ █ █ ███ █ ████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ █ █ █ █ █ █████ ████ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ █ █ █ ███ █ █ █ █ ███ ", + " ", + " ████ ███ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " ████ █ █ █ █ █ ███ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " ████ ███ █████ ███ ████ ", + " ", + " ", + " ██████ ", + " ██████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " █████ ████████ ", + " █████ ████████ " +)) + +val testFrameExtendedBolusMenuScreen = makeDisplayFrame(arrayOf( + " █ █ █████ ████ █████ █ █ ███ █████ ████ █████ █████ ████ ", + " █ █ █ █ █ █ ███ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ████ ████ █ █ █ █ ███ ████ ████ █ ████ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █████ █ █ █████ ███ ████ █████ █ █ █ █████ █ █ ", + " ", + " ████ ███ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " ████ █ █ █ █ █ ███ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " ████ ███ █████ ███ ████ ", + " ", + " ", + " ", + " █████████████ ", + " █████████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ █████ ", + " ██ █████ " +)) + +val testFrameMultiwaveBolusMenuScreen = makeDisplayFrame(arrayOf( + " █ █ █ █ █ █████ ███ █ █ █ █ █ █████ ", + " ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █████ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ███ █████ █ ███ █ █ █ █ █ █████ ", + " ", + " ████ ███ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " ████ █ █ █ █ █ ███ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " ████ ███ █████ ███ ████ ", + " ", + " ", + " ", + " ██████ ", + " ██████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ████████████ ", + " ██ ████████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ " +)) + +val testFrameBluetoothSettingsMenuScreen = makeDisplayFrame(arrayOf( + " ████ █ █ █ █████ █████ ███ ███ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ █ ████ █ █ █ █ █ █ █████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ███ █████ █ ███ ███ █ █ █ ", + " ", + " █████ ███ █ █ ████ █████ █████ █ █ █ █ █ █ ███ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ ", + " ████ █ █ █ █ ███ █ ████ █ █ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ ███ █ █ ████ █ █████ █████ █████ ███ █ █ ████ █████ █ █ ", + " ", + " ", + " ██████ ", + " ███ ████ ", + " ███ ███ ", + " ████ █ ███ ", + " ████ ██ ██ ", + " ██ █ █ ███ ", + " ███ ████ ", + " ████ ██ ", + " ███ █ ███████ ", + " ██ █ █ █ █ ", + " ████ ██ █ ██ ", + " ████ █ █ █ █ ", + " ███ █ ██ █ █ ", + " ███ ██ █ █ █ ", + " █████ ███████ " +)) + +val testFrameMenuSettingsMenuScreen = makeDisplayFrame(arrayOf( + " █ █ █████ █ █ █ █ ", + " ██ ██ █ █ █ ", + " █ █ █ █ ██ █ █ █ ", + " █ █ █ ████ █ █ █ █ █ █████ ", + " █ █ █ █ ██ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █████ █ █ ███ ", + " ", + " █████ ███ █ █ ████ █████ █████ █ █ █ █ █ █ ███ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ ", + " ████ █ █ █ █ ███ █ ████ █ █ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ ███ █ █ ████ █ █████ █████ █████ ███ █ █ ████ █████ █ █ ", + " ", + " ", + " █████████ ", + " █ █ ", + " █ █ ", + " █████████ █ ", + " █████████ █ ", + " █████████ █ ", + " █████████ █ ", + " █████ ", + " █████ ███████ ", + " █████ █ █ ", + " █████ █ ██ ", + " █████ █ █ █ ", + " █████ ██ █ █ ", + " █████ █ █ █ ", + " ███████ " +)) + +val testFrameMyDataMenuScreen = makeDisplayFrame(arrayOf( + " █ █ █████ ███ █ █ █████ ███ █ █████ █████ █ █ ", + " ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ ", + " █ █ █ ████ █ █ █ █ ████ █ █ █████ █ ████ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █████ ███ █ █ █████ ███ █ █ █ █████ █ █ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ████ ", + " ██████ ", + " ████████ ", + " ██ ██ ", + " █ ", + " ███████ █ ", + " █ █ █ ", + " █ ███ █ ███ ", + " █ █ ", + " █ ███ █ ████ ", + " █ █ █████ ", + " █ █ ██████ ", + " ███████ ██████ " +)) + +val testFrameBasalRateProfileSelectionMenuScreen = makeDisplayFrame(arrayOf( + "████ █ ████ █ █ ████ █ █████ █████ █ █ ████ ████ ███ █████ ███ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "████ █████ ███ █████ █ ████ █████ █ ████ █ █ █ ████ ████ █ █ ████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ ████ █ █ █████ █ █ █ █ █ █████ █ █ █ █ █ ███ █ ███ █████ ", + " ", + " █ █ █ ████ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ █ █ ███ █ █ █ █████ █████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ███ ████ █ █ █ █ █ █ █████ ", + " ", + " ", + " ", + " ███████ ", + " ███████ ", + " ██ ██ ", + " ██ ███████ ", + " ██ ███████ ", + " ███████ ██ ██ ", + " ███████ ██ ██ ", + " ██ ██ ██ ██ ", + " ██ ██ ██ ██ ", + " ██ ██ ██ ██ ", + " ██ ██ ██ ██ ", + " ██ ██ ██ ██ ", + " ██ ██ ██ ██ ", + " ██ ██ ██ ██ " +)) + +val testFrameProgramBasalRate1MenuScreen = makeDisplayFrame(arrayOf( + " ████ █ ████ █ █ ████ █ █████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ███ █████ █ ████ █████ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ ████ █ █ █████ █ █ █ █ █ █████ ", + " ", + " ████ ████ ███ ███ ████ █ █ █ █ █ ███ █████ ████ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ██ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " ████ ████ █ █ █ ███ ████ █████ █ █ █ █ █ █ █ ████ ████ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ███ ████ █ █ █ █ █ █ █ █ ███ █████ █ █ █████ █ █ ", + " ", + " ", + " ██ ", + " ███████ ███ ", + " ███████ ████ ", + " ██ ██ ██ ", + " ██ ███████ ██ ", + " ██ ███████ ██ ", + " ███████ ██ ██ ██ ", + " ███████ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ " +)) + +val testFrameProgramBasalRate2MenuScreen = makeDisplayFrame(arrayOf( + " ████ █ ████ █ █ ████ █ █████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ███ █████ █ ████ █████ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ ████ █ █ █████ █ █ █ █ █ █████ ", + " ", + " ████ ████ ███ ███ ████ █ █ █ █ █ ███ █████ ████ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ██ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " ████ ████ █ █ █ ███ ████ █████ █ █ █ █ █ █ █ ████ ████ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ███ ████ █ █ █ █ █ █ █ █ ███ █████ █ █ █████ █ █ ", + " ", + " ", + " ████ ", + " ███████ ██ ██ ", + " ███████ ██ ██ ", + " ██ ██ ██ ██ ", + " ██ ███████ ██ ", + " ██ ███████ ██ ", + " ███████ ██ ██ ██ ", + " ███████ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ████████ " +)) + +val testFrameProgramBasalRate3MenuScreen = makeDisplayFrame(arrayOf( + " ████ █ ████ █ █ ████ █ █████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ███ █████ █ ████ █████ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ ████ █ █ █████ █ █ █ █ █ █████ ", + " ", + " ████ ████ ███ ███ ████ █ █ █ █ █ ███ █████ ████ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ██ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " ████ ████ █ █ █ ███ ████ █████ █ █ █ █ █ █ █ ████ ████ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ███ ████ █ █ █ █ █ █ █ █ ███ █████ █ █ █████ █ █ ", + " ", + " ", + " █████ ", + " ███████ ██ ██ ", + " ███████ ██ ", + " ██ ██ ██ ", + " ██ ███████ ██ ", + " ██ ███████ ██ ", + " ███████ ██ ██ ███ ", + " ███████ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ █████ " +)) + +val testFrameProgramBasalRate4MenuScreen = makeDisplayFrame(arrayOf( + " ████ █ ████ █ █ ████ █ █████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ███ █████ █ ████ █████ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ ████ █ █ █████ █ █ █ █ █ █████ ", + " ", + " ████ ████ ███ ███ ████ █ █ █ █ █ ███ █████ ████ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ██ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " ████ ████ █ █ █ ███ ████ █████ █ █ █ █ █ █ █ ████ ████ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ███ ████ █ █ █ █ █ █ █ █ ███ █████ █ █ █████ █ █ ", + " ", + " ", + " ██ ", + " ███████ ███ ", + " ███████ ███ ", + " ██ ██ ████ ", + " ██ ███████ █ ██ ", + " ██ ███████ ██ ██ ", + " ███████ ██ ██ █ ██ ", + " ███████ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ████████ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ " +)) + +val testFrameProgramBasalRate5MenuScreen = makeDisplayFrame(arrayOf( + " ████ █ ████ █ █ ████ █ █████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ███ █████ █ ████ █████ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ ████ █ █ █████ █ █ █ █ █ █████ ", + " ", + " ████ ████ ███ ███ ████ █ █ █ █ █ ███ █████ ████ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ██ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " ████ ████ █ █ █ ███ ████ █████ █ █ █ █ █ █ █ ████ ████ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ███ ████ █ █ █ █ █ █ █ █ ███ █████ █ █ █████ █ █ ", + " ", + " ", + " ███████ ", + " ███████ ██ ", + " ███████ ██ ", + " ██ ██ ██ ", + " ██ ███████ ██ ", + " ██ ███████ ██████ ", + " ███████ ██ ██ ██ ", + " ███████ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ █████ " +)) + +val testFrameBasalRateTotalScreen0 = makeDisplayFrame(arrayOf( + "████ █ ████ █ █ ████ █ █████ █████ ███ █████ ████ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █████ ███ █████ █ ████ █████ █ ████ █ ███ ████ ███ █████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ ████ █ █ █████ █ █ █ █ █ █████ ████ █████ ████ █ █ █ █ █ ", + " ", + " ███████ ██ ███ ", + " ███████ ██ ███ ██ ", + " ███████ ██ ████ ██ ", + " ██ █ ██ ██ ██ ██ ██ ██ ", + " ███ ████████ ██ ██ ██ ██ ██ ", + " ██ █ ███████ ██████ ██ ██ ██ ██ ", + "████████ █ █ █ ██ ██ ██ ██████ ██ ██ ", + "███████ █ █ █ ███ ██ ██ ███ ██ ██ ██ ", + "██ █ █ █ █ █ █ ██ █ ██ ██ ██ ██ ██ ██ ", + "███ █ █ █ █ █ ███ ██ ██ ██ ██ ██ ██ ██ ", + "██ █ █ █ █ █ █ ██ █ ██ ██ ██ ██ ██ ██ ", + "███ █ █ █ █ █ ███ █ ██ ██ ██ ██ ██ ██ ", + "██ █ █ █ █ █ █ ██ █ ██ ███ ██ ██ ██ ██ ██ ", + "███ █ █ █ █ █ ███ █ ██ ██ ███ ██ ██ ██ ██ ██ ", + "██ █ █ █ █ █ █ ██ ███ █████ ███ ██ ████ ████ ", + " ", + " █ ████ ████ █████ ███ ███ █ █ █████ ████ █ █ ", + " ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "███ ███ ████ ████ █ █ █████ ████ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █████ ███ ███ █ █ █████ █ █ █ █ ", + " " +)) + +val testFrameBasalRateTotalScreen1 = makeDisplayFrame(arrayOf( + "████ █ ████ █ █ ████ █ █████ █████ █████ ███ █████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █████ ███ █████ █ ████ █████ █ ████ █ █ █ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ ████ █ █ █████ █ █ █ █ █ █████ █ ███ █ █ █ █████ ", + " ", + " ███████ ███ ████ ████████ ", + " ███████ ██ ██ ██ ██ ██ ", + " ███████ ██ ██ ██ ██ ██ ", + " ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██ █ ███████ ██████ ██ ██ ██ ██ ██ ██ ", + "████████ █ █ █ ██ ██ ██████ ██ ███ ██ ██ ██ ", + "███████ █ █ █ ███ ██ ███ ██ ██████ ██ ██ ██ ", + "██ █ █ █ █ █ █ ██ ███ ██ ██ ██ ██ ██ ██ ██ ", + "███ █ █ █ █ █ ███ █ █ ██ ██ ██ ██ ██ ██ ██ ", + "██ █ █ █ █ █ █ ██ █ ██ ██ ██ ██ ██ ██ ██ ", + "███ █ █ █ █ █ ███ █ ██ ██ ██ ██ ██ ██ ██ ", + "██ █ █ █ █ █ █ ██ █ ██ ██ ██ ███ ██ ██ ██ ██ ", + "███ █ █ █ █ █ ███ █ ██ ██ ██ ██ ███ ██ ██ ██ ██ ", + "██ █ █ █ █ █ █ ██ █████ █████ ████ ███ ███ ██ ████ ", + " ", + " █ █████ ███ ████ █ █ █ █████ ", + " ██ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ ███ █████ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ ", + " █ ███ ████ █ █ █ █████ ", + " " +)) + +val testFrameBasalRateFactorSettingNoFactorScreen = makeDisplayFrame(arrayOf( + " ███ ███ ███ ███ ███ ███ █████ ███ ███ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ ██ █ ██ █ █ ██ ██ █ ██ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + " ███ ███ █████ ███ ███ ███ ███ ███ ███ ", + " ", + " ", + " ███████ ", + " ███████ ", + " ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameBasalRateFactorSettingScreen0 = makeDisplayFrame(arrayOf( + " ███ ███ ███ ███ ███ ███ █████ ███ ███ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ ██ █ ██ █ █ ██ ██ █ ██ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + " ███ ███ █████ ███ ███ ███ ███ ███ ███ ", + " ", + " ████ ██ ████ ", + " ███████ ██ ██ ███ ██ ██ ", + " ███████ ██ ██ ████ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ███ ██ ████████ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameBasalRateFactorSettingScreen1 = makeDisplayFrame(arrayOf( + " ███ ███ ███ ███ ███ ███ █████ ███ ███ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ █ ██ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ ██ █ ██ █ █ ██ ██ █ ██ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + " ███ ███ █████ ███ ███ ███ ███ ███ ███ ", + " ", + " ██ ████ ████ ", + " ███████ ███ ██ ██ ██ ██ ", + " ███████ ████ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ █ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █████ ██ ████ ███ ████ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameBasalRateFactorSettingScreenAM = makeDisplayFrame(arrayOf( + " ███ █ ███ █ █ █ ███ █ █ █ █ ", + " █ █ █ ██ █ █ █ █ ██ ██ █ █ ██ █ █ ██ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + "█ ██ █ █ █ █████ █ █ █ █████ █ █ █ █ █████ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ███ █████ █ █ █ █ ███ ███ █ █ █ █ ", + " ", + " ████ ████ ███████ ", + " ███████ ██ ██ ██ ██ ██ ", + " ███████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ███ ████ █████ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameBasalRateFactorSettingScreenAMPM = makeDisplayFrame(arrayOf( + " ███ █ █ █ █ █ █ ███ ████ █ █ ", + " █ █ █ ██ ██ █ █ ██ ██ ██ █ █ █ █ ██ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █████ █ █ █ █████ █ █ ████ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ █ █ █ █ ███ █████ █ █ █ ", + " ", + " ████ ████ ████ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ███ ████ ████ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameBasalRateFactorSettingScreenPMAM = makeDisplayFrame(arrayOf( + " ███ █ █ ████ █ █ █ ███ █ █ █ ", + " █ █ █ ██ ██ █ █ ██ ██ ██ █ █ █ █ ██ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ ████ █ █ █ █████ █ █ █████ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ █ █ █ ███ █████ █ █ █ █ ", + " ", + " ████ ████ ████ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ███ ████ ████ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameBasalRateFactorSettingScreenMidnightAs24 = makeDisplayFrame(arrayOf( + " ███ ███ █████ ███ ███ ███ █ ███ ███ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ ██ █ ██ █ ██ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ ██ █ ██ █ █ █████ ██ ██ █ ██ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ ██ █ █ █ █ ", + " ███ █████ ███ ███ ███ █████ █ ███ ███ ", + " ", + " ████ ████ ████ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ █ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ███ ████ ███ ████ ████ ████ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFramePumpSettingsMenuScreen = makeDisplayFrame(arrayOf( + " ████ █ █ █ █ ████ █████ █ █ ", + " █ █ █ █ ██ ██ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ ██ █ ", + " ████ █ █ █ █ █ ████ ████ █ █ █ █████ ", + " █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " █ ███ █ █ █ █████ █ █ ", + " ", + " █████ ███ █ █ ████ █████ █████ █ █ █ █ █ █ ███ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ ", + " ████ █ █ █ █ ███ █ ████ █ █ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ ███ █ █ ████ █ █████ █████ █████ ███ █ █ ████ █████ █ █ ", + " ", + " ", + " ", + " ███████████ ", + " ███████████ ", + " ████████████ ", + " ██ ███ ", + " ██ ████ ", + " █████████████ ", + " ██ ██████ ", + " ███████████████ ██ ", + " ███████████████ █ ", + " ██ ", + " █ █ █ ", + " ██ █ █ ", + " █ █ █ ", + " ███████ " +)) + +val testFrameReminderSettingsMenuScreen = makeDisplayFrame(arrayOf( + " █████ ████ ███ █ █ █ █ █████ ████ █ █ █ █ ███ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ ██ █ ██ █ █ █ █ █ █ ██ █ █ █ ", + " ████ ████ █ █ █ █ █ █ █ ████ ████ █ █ █ █ █ █ ███ ███ █████ ", + " █ █ █ █ █ ██ █ ██ █ █ █ █ █ █ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ █ █ ███ █ █ █ █ █████ █ █ ███ █ █ ████ ████ ", + " ", + " █████ ███ █ █ ████ █████ █████ █ █ █ █ █ █ ███ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ ", + " ████ █ █ █ █ ███ █ ████ █ █ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ ███ █ █ ████ █ █████ █████ █████ ███ █ █ ████ █████ █ █ ", + " ", + " ", + " █ ", + " █ █ ", + " ███ ", + " █ █ █ ", + " █ █ █ ", + " █ █ ██ ", + " █ █ █ ", + " █ █ █ ", + " █ █ ███████ ", + " █ █ █ █ █ ", + " █ █ █ ██ ", + " █████████ █ █ █ ", + " ███ ██ █ █ ", + " █ █ █ █ ", + " ███████ " +)) + +val testFrameTimeAndDateSettingsMenuScreen = makeDisplayFrame(arrayOf( + " █████ █████ ███ █████ █ █ █ █ ███ ███ █ █████ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ █████ ███ █ ███ █ █ ███ ███ █ █ █ ███ █ █ ", + " ", + " █████ ███ █ █ ████ █████ █████ █ █ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ██ █ █ █ █ █ █ █ ██ █ ", + " ████ █ █ █ █ ███ █ ████ █ █ ████ █ █ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ ███ █ █ ████ █ █████ █████ █████ █████ █ █ ", + " ", + " ", + " ", + " █████ ", + " █ █ ", + " ██████ █ █ ", + " █ █ █ █ ", + " █████ █ █ ", + " █ █ █ ███ █ ", + " █████ ", + " █ █ █ ███████ ", + " ██████ █ █ ", + " █ █ █ █ █ ██ ", + " █████████ █ █ █ ", + " █ █ █ █ █ ██ █ █ ", + " █████████ █ █ █ ", + " ████████ ███████ " +)) + +val testFrameStopPumpMenuScreen = makeDisplayFrame(arrayOf( + " ████ █ █ █ █ ████ █████ ", + " █ █ █ █ ██ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ █ █ █ ████ ████ ", + " █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ ███ █ █ █ █████ ", + " ", + " ████ █████ ███ ████ ████ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ ██ █ ", + " ███ █ █ █ ████ ████ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ████ █ ███ █ █ █████ █ █ ", + " ", + " ", + " ████████ ", + " ██████████ ", + " ████████████ ", + " ██████████████ ", + " ████████████████ ", + " █ █ █ █ ██ ", + " █ ███ ██ █ █ █ █ ", + " █ ██ ██ █ █ ██ ", + " ██ ██ ██ █ █ ███ ", + " █ ██ ██ █ ███ ", + " ████████████████ ", + " ██████████████ ", + " ████████████ ", + " ██████████ ", + " ████████ " +)) + +val testFrameTemporaryBasalRateMenuScreen = makeDisplayFrame(arrayOf( + " █████ █████ █ █ ████ ███ ████ █ █ ████ █████ ", + " █ █ ██ ██ █ █ █ █ █ █ ███ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ ████ █ █ ████ █ █ ████ ████ ", + " █ █ █ █ █ █ █ █ █ █████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █████ █ █ █ ███ █ █ █ █ █ █ █████ ", + " ", + " ████ █ ████ █ █ ████ █ █████ █████ █ █████ ████ ████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ███ █████ █ ████ █████ █ ████ █ █ ████ ████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █ █ ████ █ █ █████ █ █ █ █ █ █████ █ █ ████ █ █ █ ", + " ", + " ", + " ", + " ███████ ██ ██ ", + " ███████ ████ ██ ", + " ██ ██ ████ ██ ", + " ██ ███████ ██ ██ ", + " ██ ███████ ██ ", + " ███████ ██ ██ ██ ", + " ███████ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ████ ", + " ██ ██ ██ ██ ██ ████ ", + " ██ ██ ██ ██ ██ ██ " +)) + +val testFrameTherapySettingsMenuScreen = makeDisplayFrame(arrayOf( + " █████ █ █ █████ ████ █ ████ ███ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █████ ████ ████ █████ ████ █ ████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █████ █ █ █ █ █ ███ █████ ", + " ", + " █████ ███ █ █ ████ █████ █████ █ █ █ █ █ █ ███ █████ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ ", + " ████ █ █ █ █ ███ █ ████ █ █ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █████ ███ █ █ ████ █ █████ █████ █████ ███ █ █ ████ █████ █ █ ", + " ", + " ", + " ████ ", + " █ █ ", + " █ █ ", + " ████ ████ ", + " █ █ ", + " █ █ ", + " ████ ████ ", + " █ █ ", + " █ █ ███████ ", + " ████ █ █ ", + " █ ██ ", + " █ █ █ ", + " ██ █ █ ", + " █ █ █ ", + " ███████ " +)) + +val testFrameQuickinfoMainScreen = makeDisplayFrame(arrayOf( + " ███ █ █ ███ ███ █ █ ███ █ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ██ █ ███ ███ ███ █ █ ███ █ █ █ ███ ", + " ", + " ████ ██ █████ ", + "████████████████████ ██ ██ ███ ██ ██ ", + "████████████████████ ██ ██ ████ ██ ", + "████████████████████ ███ ██ ██ ██ ██ ██ ██ ", + "██████████████████████ █ ██ ██ ██ ██ ██ ", + "██████████████████████ █ ██ ██ ██ ██ ██ ", + "██████████████████████ █ ██ ██ ███ ██ ██ ", + "██████████████████████ █ ██ ██ ██ ██ ██ ", + "████████████████████ ███ ██ ██ ██ ██ ██ ", + "████████████████████ ██ ██ ██ ██ ██ ", + "████████████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ██ █████ ████ ", + " ", + " ", + " ██ █ █ █ █ █ ███ ███ ", + " █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + " ██ █ █ █ █ █ █ █ █ █ ██ █ ██ ", + " ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ █ █ █ █ ██ █ ██ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ██ █ █ ███ ███ ███ ███ " +)) + +val testFrameW6CancelTbrWarningScreen = makeDisplayFrame(arrayOf( + "███ ████ █████ ███ █ █ █ ███ █████ █ █ ███ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █████ █ █ █ █ ████ █ █████ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ████ █ ███ █ █ █ █ ███ █████ █████ █ █ ███ █ █ ", + " ", + " ██ ██ ██ ███ ", + " ████ ██ ██ ██ ", + " █ █ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ", + " █ ██ █ ██ ██ ██ ██████ ", + " ██ ██ ██ ██ ██ ██ ███ ██ ", + " █ ██ █ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ████ ██ ██ ██ ", + " █ █ ██████████ ██ ██ ", + " ████████████████ ███ ███ ██ ██ ", + " ███████████████ █ █ ████ ", + " ", + " ", + " █ ████ █████ ████ █████ █████ ███ ████ ████ █████ █ █ █ █ ", + " ██ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ████ ████ ████ ████ █ █ ████ ███ ████ ██ █ █████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ", + " █ █ █████ █ █████ █ ███ █ █ ████ █████ █ █ █ █ █████ " +)) + +val testFrameW8CancelBolusWarningScreen0 = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ █ ████ ████ ████ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █████ ████ ████ ████ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ █ █ ████ ████ █ █ ███ ███ █ █ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " █ █ █ █████ █ █ █████ ████ ███ █ █ █ █ █████ ████ █ █ ", + " ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ ██ █ █ █ █ █ █ █ █ ██ █ ██ █ █ █ █ ██ █ ", + "███ █ █ █ ████ █ █ ████ ████ █ █ █ █ █ █ █ ████ ████ █ █ █ ", + " █ █ ██ █ █ █ █ █ █ █ █ ██ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █████ ███ █████ █ █ ███ █ █ █ █ █████ █ █ █ █ " +)) + +val testFrameW8CancelBolusWarningScreen1 = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ █ ████ ████ ████ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █████ ████ ████ ████ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ █ █ ████ ████ █ █ ███ ███ █ █ ", + " ", + " ██ ██ ██ ████ ", + " ████ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " █ ██ █ ██ ██ ██ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " █ ██ █ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ████ ██ ██ ██ ", + " █ █ ██████████ ██ ██ ", + " ████████████████ ███ ███ ██ ██ ", + " ███████████████ █ █ ████ ", + " ", + " ", + " █ █ █ █████ █ █ █████ ████ ███ █ █ █ █ █████ ████ █ █ ", + " ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ ██ █ █ █ █ █ █ █ █ ██ █ ██ █ █ █ █ ██ █ ", + "███ █ █ █ ████ █ █ ████ ████ █ █ █ █ █ █ █ ████ ████ █ █ █ ", + " █ █ ██ █ █ █ █ █ █ █ █ ██ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █████ ███ █████ █ █ ███ █ █ █ █ █████ █ █ █ █ " +)) + +val testFrameW8CancelBolusWarningScreen2 = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ █ ████ ████ ████ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █████ ████ ████ ████ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ █ █ ████ ████ █ █ ███ ███ █ █ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " █ ████ █████ ████ █████ █ █ █████ ███ ███ █████ █ █ ", + " ██ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "███ ████ ████ ███ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ █ █ █ █████ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ████ █ █ █ █ ███ ████ █████ █ █ " +)) + +val testFrameW8CancelBolusWarningScreen3 = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ █ ████ ████ ████ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █████ ████ ████ ████ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ █ █ ████ ████ █ █ ███ ███ █ █ ", + " ", + " ██ ██ ██ ████ ", + " ████ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " █ ██ █ ██ ██ ██ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " █ ██ █ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ████ ██ ██ ██ ", + " █ █ ██████████ ██ ██ ", + " ████████████████ ███ ███ ██ ██ ", + " ███████████████ █ █ ████ ", + " ", + " ", + " █ ████ █████ ████ █████ █ █ █████ ███ ███ █████ █ █ ", + " ██ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "███ ████ ████ ███ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ █ █ █ █████ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ████ █ █ █ █ ███ ████ █████ █ █ " +)) + +val testFrameE2BatteryEmptyErrorScreen0 = makeDisplayFrame(arrayOf( + "████ █ █████ █████ █████ ████ ███ █████ █ █████ █████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █████ █ █ ████ ████ █ ████ █ ████ ████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █████ █ █ ███ █████ █████ █████ █████ █ █ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " █ ████ █████ ████ █████ █ █ █████ ███ ███ █████ █ █ ", + " ██ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "███ ████ ████ ███ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ █ █ █ █████ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ████ █ █ █ █ ███ ████ █████ █ █ " +)) + +val testFrameE2BatteryEmptyErrorScreen1 = makeDisplayFrame(arrayOf( + "████ █ █████ █████ █████ ████ ███ █████ █ █████ █████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █████ █ █ ████ ████ █ ████ █ ████ ████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █████ █ █ ███ █████ █████ █████ █████ █ █ ", + " ", + " █████ ████████ ████ ████████ ", + " █████████ ██ ██ ██ ██████████ ", + " ███████████ ██ ██ ██ ████████████ ", + " ███ █████ ███ ██ ██ ██ ██████████████ ", + " ██ ███ ██ ██ ██ ████████████████ ", + " ████ █ ████ ██ ██ █ █ █ █ ██ ", + " █████ █████ ██ ██ █ ███ ██ █ █ █ █ ", + " ██████ ██████ ███████ ██ █ ██ ██ █ █ ██ ", + " █████ █████ ██ ██ ██ ██ ██ █ █ ███ ", + " ████ █ ████ ██ ██ █ ██ ██ █ ███ ", + " ██ ███ ██ ██ ██ ████████████████ ", + " ███ █████ ███ ██ ██ ██████████████ ", + " ███████████ ██ ██ ████████████ ", + " █████████ ██ ██ ██████████ ", + " █████ ████████ ████████ ████████ ", + " ", + " ", + " █ ████ █████ ████ █████ █ █ █████ ███ ███ █████ █ █ ", + " ██ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "███ ████ ████ ███ █ █ █ █ █ █ ███ ████ █ █ █ ", + " █ █ █ █ █ █ █████ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ █████ ████ █ █ █ █ ███ ████ █████ █ █ " +)) + +val testFrameTemporaryBasalRatePercentage100Screen = makeDisplayFrame(arrayOf( + "█████ ████ ████ █ █ █████ ████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █ █ █ ████ ████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █████ █ █ █ ", + " ", + " ██ ████ ████ ██ ██ ", + " ███████ ███ ██ ██ ██ ██ ████ ██ ", + " ███████ ████ ██ ██ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ████ ████ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameTemporaryBasalRatePercentage110Screen = makeDisplayFrame(arrayOf( + "█████ ████ ████ █ █ █████ ████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █ █ █ ████ ████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █████ █ █ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTemporaryBasalRateNoPercentageScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ █ █ █████ ████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █ █ █ ████ ████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █████ █ █ █ ", + " ", + " ██ ██ ", + " ███████ ████ ██ ", + " ███████ ████ ██ ", + " ██ ██ ██ ██ ", + " ██ ███████ ██ ", + " ██ ███████ ██ ", + "███████ ██ ██ ██ ", + "███████ ██ ██ ██ ", + "██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameTbrDurationNoDurationScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ███ █ █ ████ █ █████ ███ ███ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ ████ ████ █ █ █ █ ████ █████ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ ███ ███ █ █ █ █ █ ███ ███ █ █ ", + " ", + " ", + " ██ ", + " ███ ", + " ████ ", + " █████ ", + " ██████ ", + "███████████████ ", + "████████████████ ", + "████████████████ ", + "███████████████ ", + " ██████ ", + " █████ ", + " ████ ", + " ███ ", + " ██ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageEnglishScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ████ █████ ████ ███ █████ █ █ █████ █ ███ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + " █ ████ ████ ████ ████ ████ █ ████ █ █ █ █ █████ █ ███ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █████ █ █ ███ █████ █ █ █ █ █ ████ █████ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) +val testFrameTbrDurationEnglishScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ███ █ █ ████ █ █████ ███ ███ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ ████ ████ █ █ █ █ ████ █████ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ ███ ███ █ █ █ █ █ ███ ███ █ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageSpanishScreen = makeDisplayFrame(arrayOf( + "████ ███ ████ ███ █████ █ █ █████ █ ███ █████ ███ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ ████ █ ████ █ █ █ █ █████ █ ████ █ █ ████ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ███ █ █ ███ █████ █ █ █ █ █ ██ █████ ███ ████ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) +val testFrameTbrDurationSpanishScreen = makeDisplayFrame(arrayOf( + "███ █ █ ████ █ ███ ███ █ █ █ ███ █████ ███ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ███ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ ████ █ █ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ███ █ █ █ █ ███ ███ ███ █ █ ███ █████ ███ ████ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageFrenchScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █████ █ █ ████ ███ █ █ ███ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ ████ █ █ ████ █ █ █ █ █ █ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █████ █████ ███ █ █ ███ ███ ███ ████ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationFrenchScreen = makeDisplayFrame(arrayOf( + "███ █ █ ████ █ █████ ███ █ █ ███ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ████ █ ████ █ █ █ █ █ █ ████ █ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ███ █ █ █████ █████ ███ ███ ███ ████ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageItalianScreen = makeDisplayFrame(arrayOf( + "████ █████ ████ ███ █████ █ █ █████ █ █ █ █ █████ ████ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ████ ████ █ ████ █ █ █ █ █ █ █████ █ ████ ████ ████ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █████ █ █ ███ █████ █ █ █ ███ █ █ █████ █████ █ ████ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationItalianScreen = makeDisplayFrame(arrayOf( + "███ █ █ ████ █ █████ █ ████ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ████ █████ █ █████ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ███ █ █ █ █ █ █ █ █ ████ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageRussianScreen = makeDisplayFrame(arrayOf( + "█████ ████ ███ █ █ █████ █ █ █████ ████ █████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █ █ ████ █████ █ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ███ █ █████ █ █ █ ████ ████ ███ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationRussianScreen = makeDisplayFrame(arrayOf( + "█████ ████ ███ ██ ███ ████ █ █ █ █ █ █████ ████ █████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █ █ █ █ █ █ ███ █ █ █ █ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █████ █ █ █ █ █ █ █ ██ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ ███ █ █ ███ ██ █ █ █ █ █ █ █ ██ ████ ████ ███ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageTurkishScreen = makeDisplayFrame(arrayOf( + " ███ ████ █ █ █ █ █ █ █████ ███ █████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ███ ", + "█ ███ ████ █████ █ █ █ █ █ █ █ ████ ███ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ ████ █ █ █ ███ █████ ███ █████ ████ ███ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationTurkishScreen = makeDisplayFrame(arrayOf( + " ███ ████ █ █ ████ █ █ ████ █████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ███ ", + "█ ███ ████ █████ ███ █ █ ████ ████ ███ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ ████ █ █ ████ ███ █ █ █████ ████ ███ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentagePolishScreen = makeDisplayFrame(arrayOf( + "████ ████ ███ ███ █████ █ █ █████ █████ ███ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + "████ ████ █ █ █ ████ █ █ █ █ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ███ ███ █████ █ █ █ █ ███ █ ", + " ", + " ██ ████ ████ ██ ██ ", + " ███████ ███ ██ ██ ██ ██ ████ ██ ", + " ███████ ████ ██ ██ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ████ ████ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " " +)) + +val testFrameTbrDurationPolishScreen = makeDisplayFrame(arrayOf( + " ███ █████ █ ████ █████ ████ █ █ █ █ █ ███ █ █████ ███ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ███ █ ████ █ █ █ █████ █ █ █ █ █████ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █████ █ █ ████ █ █ █ █ █ █ █ █ █ ███ █ █ █ ███ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageCzechScreen = makeDisplayFrame(arrayOf( + "████ ████ ███ ███ █████ █ █ █████ ███ ███ ████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "████ ████ █ █ █ ████ █ █ █ █ █ █ █ █ ████ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ███ ███ █████ █ █ █ ███ ███ ████ ███ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationCzechScreen = makeDisplayFrame(arrayOf( + "█████ ████ █ █ █ █ █ █ ███ ████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ ███ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ ", + " █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ███ ███ ████ ███ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageHungarianScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ████ █████ █ █████ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ ", + " █ ████ ████ ███ █ █ █ █ █████ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ████ █ █ ", + " █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ ", + " █ ████ █ █ ████ █████ █ █ █████ █ █ █████ █████ █ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationHungarianScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ███ ███ █ █ █████ █ ████ █████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ", + " █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █ █ █ █ █ █ █████ ████ █ █████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ ███ ███ ███ █ █ █ █ █ █ █ █ █ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageSlovakScreen = makeDisplayFrame(arrayOf( + "████ █████ ████ ███ █████ █ █ █████ ███ ███ ████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "████ ████ ████ █ ████ █ █ █ █ █ █ █ █ ████ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █████ █ █ ███ █████ █ █ █ ███ ███ ████ ███ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationSlovakScreen = makeDisplayFrame(arrayOf( + "█████ ████ █ █ █ █ █ ███ █████ ███ ████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █████ █ █ █ █ ████ █ █ ████ █ █ ", + " █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ███ █████ ███ ████ ███ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageRomanianScreen = makeDisplayFrame(arrayOf( + "████ ████ ███ ███ █████ █ █ █████ ████ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + "████ ████ █ █ █ ████ █ █ █ █ ████ ████ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ███ ███ █████ █ █ █ █ █ ████ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationRomanianScreen = makeDisplayFrame(arrayOf( + "███ █ █ ████ █ █████ █ ████ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ████ █████ █ █████ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ███ █ █ █ █ █ █ █ █ █ ████ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageCroatianScreen = makeDisplayFrame(arrayOf( + "████ ███ ████ █████ ███ █████ █ █ █ ████ ████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ███ ", + "████ █ █ ███ █ █ █ █ █████ ██ ████ ████ █ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ███ ████ █ ███ █ █ █ █ █ █ ████ ███ ████ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationCroatianScreen = makeDisplayFrame(arrayOf( + "█████ ████ █ ███ █ █ █ ███ █████ ████ ████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ ", + " █ ████ █████ █ █████ █ █ █ █ ████ ████ ████ █ █ █████ █ ", + " █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ ██ █████ █ ████ ███ ████ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageDutchScreen = makeDisplayFrame(arrayOf( + "█████ ████ ███ ████ █████ ████ ███ █████ █ █ █████ █ ███ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + " █ ████ █ █ █████ ████ ████ ████ █ ████ █ █ █ █ █████ █ ███ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ███ █ █████ █ █ ███ █████ █ █ █ █ █ ████ █████ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationDutchScreen = makeDisplayFrame(arrayOf( + "█████ ████ ███ ███ █ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █████ █ █ █ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ███ ███ ███ ███ █ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageGreekScreen = makeDisplayFrame(arrayOf( + "█████ ███ █████ ███ █████ █████ ███ █████ ████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ██ █ ██ ", + "█ █ ███ █████ ███ █████ █ ███ █ █ ██ ████ ██ █ ██ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationGreekScreen = makeDisplayFrame(arrayOf( + " █ ███ █ ████ █ █ █████ ███ █ █████ ████ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ ████ ██ ████ █ █ █ █ █ ████ ████ ", + "█ █ █ █████ █ █ █ █ █ █████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ██ █ ██ ", + "█████ ███ █ █ █ █ █ █████ ███ █ █ █ █ ██ ████ ██ █ ██ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageFinnishScreen = makeDisplayFrame(arrayOf( + "█████ ████ █ ████ ████ ███ ████ █████ █ █ █████ █████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + " █ ████ █████ █████ ████ ████ █ █ ███ ████ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █ ███ ████ █████ █ █ █ █ ███ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationFinnishScreen = makeDisplayFrame(arrayOf( + "█████ ████ █ █ █ █████ ████ █████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █████ █████ ██ ████ ███ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █████ ████ █ ███ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageNorwegianScreen = makeDisplayFrame(arrayOf( + "█ █ ████ ███ ████ ████ ███ ████ █████ █ █ █████ ", + "██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ ████ █ █ █████ ████ ████ █ █ ███ ████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ ███ █ █ █ ███ ████ █████ █ █ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationNorwegianScreen = makeDisplayFrame(arrayOf( + "█ █ ████ ███ █ █ █ ████ ███ ███ █ █ █████ █████ ", + "██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ █ █ █████ █ █ █████ ████ █ █ ███ █████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ ███ █ █ █ █ █ ███ ████ █ █ █████ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentagePortugueseScreen = makeDisplayFrame(arrayOf( + "███ ████ █████ ████ █████ ████ ███ █████ █ █ █████ █ ███ █████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ ████ ████ ████ █ ████ █ █ █ █ █████ █ ███ ████ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ████ █ █ █████ █ █ ███ █████ █ █ █ █ █ ████ █████ █ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationPortugueseScreen = makeDisplayFrame(arrayOf( + "███ ████ █████ ███ █ █ ████ █ ████ █ █ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █ █ █ ████ █████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ ", + "███ ████ █ ███ ███ █ █ █ █ ██ █ █ ███ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageSwedishScreen = makeDisplayFrame(arrayOf( + "█████ ████ ███ ████ ████ ███ ███ █████ █ █ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ", + " █ ████ █ █ ████ ████ █ █ █ ████ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ███ █ █ █ ███ ███ █████ █ █ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationSwedishScreen = makeDisplayFrame(arrayOf( + "█████ ████ ███ ███ █ █ ████ █ █████ ███ ███ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ ████ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ███ ███ ███ █ █ █ █ █ ███ ███ █ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageDanishScreen = makeDisplayFrame(arrayOf( + "█ █ ████ ████ ████ ████ ███ ███ █████ █ █ █████ ", + "██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ ████ ████ █████ ████ ████ █ █ █ ████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █ █ █ ███ ███ █████ █ █ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationDanishScreen = makeDisplayFrame(arrayOf( + "█ █ ████ ████ █ █ █ ████ ███ ███ █ █ █████ ███ ", + "██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ ████ █████ █ █ █████ ████ █ █ ███ █████ ████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █ █ █ █ █ ███ ████ █ █ █████ ███ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testFrameTbrPercentageGermanScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ █ █ █████ ████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █ █ █ ████ ████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █████ █ █ █ ", + " ", + " ██ ██ ████ ██ ██ ", + " ███████ ███ ███ ██ ██ ████ ██ ", + " ███████ ████ ████ ██ ██ ████ ██ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + " ██ ███████ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "███████ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ██ ██ ██ ██ ████ ", + " ", + " █ ███ ███ █████ ███ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " " +)) + +val testFrameTbrDurationGermanScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ███ █ █ █ █████ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █ █ █████ █ █ ████ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ ███ █ █ ███ █████ █ █ ", + " ", + " ████ ████ █████ ████ ", + " ██ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ███ ███ ██ ██ ", + "████████████████ ██ ██ ██ ██ ███ ██ ██ ██ ", + "████████████████ ██ ██ ██ ██ ██ ██ ██ ", + "███████████████ ██ ██ ██ ██ ██ ██ ██ ", + " ██████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " █████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ████ ██ ██ ██ ██ ███ ██ ██ ██ ", + " ███ ██ ██ ██ ██ ██ ██ ██ ██ ", + " ██ ████ ████ █████ ████ ", + " ", + " ████ █ █ ███ ██ ", + " █ ███ ██ ██ █ █ ██ █ ", + "███ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ███ ███ ███ ██ ", + " " +)) + +val testTimeAndDateSettingsHour12hFormatScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ ████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█████ █ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ ███ ███ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ ██ █ █ █ ", + "█ █ █ ██ ██ █ █ █ ", + "█ █ ██ ████ █ █ █ ", + "█ ████ ██ ██ ██ ", + "█ ██ ██ ██ ", + "█ ██ ██ ██ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ", + " █████ ████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ ██ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ █ █ ████ █ █ █ █ ████ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █████ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █ █ █ █ █████ ███ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHour24hFormatScreen = makeDisplayFrame(arrayOf( + " ████ █████ █ █ █ █ ███ █████ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + " ███ █ █ █ █ █ █ █ █ ████ ", + " █ █ █ █ █ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ █ ", + "████ █ ███ █ █ ███ █████ ", + " ", + " ██ ████ ", + " █████ ███ ██ ██ ", + " ██ ██ ████ ██ ██ ", + " █ █ █ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ██ ██ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + " █ ███ ██ ██ ██ ", + " █ ██ ██ ██ ██ ", + " ██ ████ ██ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ ██ ████ ", + " ", + " █ ███ ███ ███ ███ █ ███ █ █ █████", + " ██ █ █ ██ █ █ █ █ █ █ ██ █ █ ██ ██ █ ", + " █ █ ██ ██ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █████ █████ █████ ███ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourEnglishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ ████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█████ █ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ ███ ███ █ █ ", + " ", + " ████ █ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ █████ █ █ █ ", + " █ █ ██ ██ ██ █ █ █ █ ", + "█ █ █ ██ ██ █ █ █ █ ", + "█ █ ██ ████ █ █ █ █ ", + "█ ████ ██ ██ ██ ", + "█ ██ ██ ██ ", + "█ ██ ██ ██ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ", + " █████ ████ ", + " ", + " ███ ███ █████ █████ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ ████ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteEnglishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █████ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ████ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █████ ", + " ", + " █████ ███████ ", + " █████ ██ ██ ██ ", + " ██ ██ ██ ██ ", + " █ █ █ ██ ██ ", + " █ █ ██ ██ ██ ", + "█ █ █ ██ ██████ ", + "█ █ ██ ███ ██ ", + "█ ████ ██ ██ ██ ", + "█ ██ ██ ██ ", + "█ ██ ██ ██ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ██ ", + " █████ █████ █████ ", + " ", + " ███ ███ █████ █████ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ ████ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearEnglishScreen = makeDisplayFrame(arrayOf( + "█ █ █████ █ ████ ", + "█ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ", + " █ █ ████ █████ ████ ", + " █ █ █ █ █ █ ", + " █ █ █ █ █ █ ", + " █ █████ █ █ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █████ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ ████ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthEnglishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █████ █ █ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ █ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ █ █ █ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █████ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ ████ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayEnglishScreen = makeDisplayFrame(arrayOf( + "███ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █████ █ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "███ █ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █████ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ ████ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourSpanishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ ████ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█████ █ █ ████ █████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ █ █ ", + " ", + " ████ █ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ █████ █ █ █ ", + " █ █ ██ ██ ██ █ █ █ █ ", + "█ █ █ ██ ██ █ █ █ █ ", + "█ █ ██ ████ █ █ █ █ ", + "█ ████ ██ ██ ██ ", + "█ ██ ██ ██ ", + "█ ██ ██ ██ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ", + " █████ ████ ", + " ", + " ███ ███ █████ ██ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteSpanishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ ███ ", + "██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ ███ ", + " ", + " █████ ███ ", + " █████ ██ ██ ██ ", + " ██ ██ ██ ██ ", + " █ █ █ ██ ██ ", + " █ █ ██ ██ ██ ", + "█ █ █ ██ ██ ", + "█ █ ██ ███ ██████ ", + "█ ████ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + " █ ███ ██ ██ ██ ", + " █ ██ ██ ██ ██ ", + " ██ ████ ██ ██ ██ ", + " █████████ ██ ██ ██ ██ ", + " █████ █████ ████ ", + " ", + " ███ ███ █████ ██ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearSpanishScreen = makeDisplayFrame(arrayOf( + " █ █ █ ███ ", + " █ █ █ ██ █ █ ", + "█ █ █ █ █ █ ", + "█████ ██ █ █ █ ", + "█ █ █ █ █ █ █ ", + "█ █ █ ██ █ █ ", + "█ █ █ █ ███ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ ██ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthSpanishScreen = makeDisplayFrame(arrayOf( + "█ █ █████ ████ ", + "██ ██ █ █ ", + "█ █ █ █ █ ", + "█ █ █ ████ ███ ", + "█ █ █ █ ", + "█ █ █ █ ", + "█ █ █████ ████ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ ██ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDaySpanishScreen = makeDisplayFrame(arrayOf( + "███ █ █ ", + "█ █ █ █ █ ", + "█ █ ███ █ █ ", + "█ █ █ █████ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "███ ███ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ ██ █ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ ███ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourFrenchScreen = makeDisplayFrame(arrayOf( + "█ █ █████ █ █ ████ █████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█████ ████ █ █ ████ ████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █████ ███ █ █ █████ ", + " ", + " ██ ████ █ █ █ ", + " █████ ███ ██ ██ █ █ ██ ██ ", + " ██ ██ ████ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ██ █████ █ █ █ ", + " █ █ ██ ██ ██ ██ █ █ █ █ ", + "█ █ █ ██ ██ ██ █ █ █ █ ", + "█ █ ██ ██ ██ ██ █ █ █ █ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + " █ ███ ██ ██ ██ ", + " █ ██ ██ ██ ██ ", + " ██ ████ ██ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ ██ ████ ", + " ", + " █ ███ ███ █ █ █ █ ███ █████ ███ █ █ █████", + " ██ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + " █ █ ██ ██ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ ██ █ █████ █ █ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteFrenchScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █████ ████ ", + "██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ████ ███ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █████ ████ ", + " ", + " ██ ", + " █████ ███ ", + " ██ ██ ███ ", + " █ █ █ ████ ", + " █ █ ██ █ ██ ", + "█ █ █ ██ ██ ", + "█ █ ██ █ ██ ", + "█ ████ ██ ██ ██ ", + "█ ██ ██ ██ ", + "█ ██ ████████ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ██ ", + " ", + " █ ███ ███ █ █ █ █ ███ █████ ███ █ █ █████", + " ██ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + " █ █ ██ ██ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ ██ █ █████ █ █ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearFrenchScreen = makeDisplayFrame(arrayOf( + " █ █ █ █ █ █ █████ ", + " █ █ █ █ █ █ █ █ ", + "█ █ ██ █ ██ █ █████ █ ", + "█████ █ █ █ █ █ █ █ ████ ", + "█ █ █ ██ █ ██ ████ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █████ █████ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " █ ███ ███ █ █ █ █ ███ █████ ███ █ █ █████", + " ██ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + " █ █ ██ ██ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ ██ █ █████ █ █ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthFrenchScreen = makeDisplayFrame(arrayOf( + "█ █ ███ ███ ████ ", + "██ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ ███ ███ ████ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " █ ███ ███ █ █ █ █ ███ █████ ███ █ █ █████", + " ██ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + " █ █ ██ ██ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ ██ █ █████ █ █ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayFrenchScreen = makeDisplayFrame(arrayOf( + " ███ ███ █ █ ████ ", + " █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █ █ █ ████ ", + " █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + " ██ ███ ███ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " █ ███ ███ █ █ █ █ ███ █████ ███ █ █ █████", + " ██ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + " █ █ ██ ██ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ ██ █ █████ █ █ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourItalianScreen = makeDisplayFrame(arrayOf( + " ███ █ █ ████ ███ ████ █████ █ ████ █████ ███ ████ █ ", + " █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ ████ █ █ ███ █ █████ ████ ████ █ █ ████ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ █ ███ ████ █ █ █ █ █ █████ ███ █ █ █ █ ", + " ", + " ██ ████ █ █ ", + " █████ ███ █ █ ██ ██ ", + " ██ ██ ████ █ █ █ █ █ ", + " █ █ █ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ██ ", + " ", + " ███ █ █ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ███ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █████ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteItalianScreen = makeDisplayFrame(arrayOf( + " ███ █ █ ████ ███ ████ █████ █ ████ █████ █ █ ███ █ █ █ █ █████ ███ ", + " █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ ", + " █ █ █ █ ████ █ █ ███ █ █████ ████ ████ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ █ ███ ████ █ █ █ █ █ █████ █ █ ███ █ █ ███ █ ███ ", + " ", + " ██ ████ ", + " █████ ███ ██ ██ ", + " ██ ██ ███ ██ ██ ", + " █ █ █ ████ ██ ██ ", + " █ █ ██ █ ██ ██ ██ ", + "█ █ █ ██ ██ ██ ██ ", + "█ █ ██ █ ██ ████ ", + "█ ████ ██ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ██ ", + "█ ██ ████████ ██ ██ ", + " █ ███ ██ ██ ██ ", + " █ ██ ██ ██ ██ ", + " ██ ████ ██ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ ██ ████ ", + " ", + " ███ █ █ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ███ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █████ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearItalianScreen = makeDisplayFrame(arrayOf( + " ███ █ █ ████ ███ ████ █████ █ ████ █████ █ █ █ █ █ ███ ", + " █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ██ █ █ █ ", + " █ █ █ █ ████ █ █ ███ █ █████ ████ ████ █████ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ██ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ █ ███ ████ █ █ █ █ █ █████ █ █ █ █ █ █ ███ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ █ █ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ███ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █████ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthItalianScreen = makeDisplayFrame(arrayOf( + " ███ █ █ ████ ███ ████ █████ █ ████ █████ █ █ █████ ████ █████ ", + " █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ ██ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ ████ █ █ ███ █ █████ ████ ████ █ █ █ ████ ███ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ █ ███ ████ █ █ █ █ █ █████ █ █ █████ ████ █████ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ █ █ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ███ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █████ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayItalianScreen = makeDisplayFrame(arrayOf( + " ███ █ █ ████ ███ ████ █████ █ ████ █████ ███ ███ ███ ████ █ █ ███ ", + " █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ", + " █ █ █ █ ████ █ █ ███ █ █████ ████ ████ █ ███ █ █ █ ████ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ █ ███ ████ █ █ █ █ █ █████ ████ ███ ███ █ █ █ █ ███ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ █ █ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ███ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █████ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourRussianScreen = makeDisplayFrame(arrayOf( + "█ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ", + "█ ██ █ █ █ ██ █ ", + " ██ █ █████ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ ███ ██ █ ", + " ", + " ██ ████ █ █ ", + " █████ ███ █ █ ██ ██ ", + " ██ ██ ████ █ █ █ █ █ ", + " █ █ █ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ██ ", + " ", + " ███ █ █████ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █████ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteRussianScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ █ █ █████ █ █ ", + "██ ██ █ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █████ ███ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ ", + " ", + " ███████ ████ ", + " █████ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ", + " █ █ █ ██ ██ ██ ", + " █ █ ██ ██ ██ ", + "█ █ █ ██████ ██ ", + "█ █ ██ ██ ██ ", + "█ ████ ██ ██ ██ ", + "█ ██ ██ ██ ", + "█ ██ ██ ██ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ████████ ", + " ", + " ███ █ █████ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █████ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearRussianScreen = makeDisplayFrame(arrayOf( + "█████ ███ ██ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █ █████ ", + "█ ███ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ █ █████ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █████ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthRussianScreen = makeDisplayFrame(arrayOf( + "█ █ █████ ███ ████ █ █ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ ████ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █████ ", + "█ █ █████ ███ █ █ █ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ █ █████ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █████ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayRussianScreen = makeDisplayFrame(arrayOf( + " ██ █████ █ █ █ ", + " █ █ █ █ █ █ ", + " █ █ █ █ █ █ ", + "█ █ ████ █████ ███ ", + "█ █ █ █ █ █ █ ", + "█████ █ █ █ █ █ ", + "█ █ █████ █ █ ███ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ █ █████ ███ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ █████ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourTurkishScreen = makeDisplayFrame(arrayOf( + " ████ █ █ █████ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + " ███ █████ █████ █ ", + " █ █ █ █ █ █ ", + " █ █ █ █ █ █ ", + "████ █ █ █ █ █ ", + " ", + " ██ ████ █ █ ", + " █████ ███ █ █ ██ ██ ", + " ██ ██ ████ █ █ █ █ █ ", + " █ █ █ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ██ ", + " ", + " ███ █ █████ █████ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteTurkishScreen = makeDisplayFrame(arrayOf( + "███ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ █████ ██ █ ██ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ █ ███ █ █ █ █ ", + " ", + " ███████ █████ ", + " █████ ██ ██ ██ ", + " ██ ██ ██ ██ ", + " █ █ █ ██ ██ ", + " █ █ ██ ██ ██ ", + "█ █ █ ██████ ██ ", + "█ █ ██ ██ ███ ", + "█ ████ ██ ██ ██ ", + "█ ██ ██ ██ ", + "█ ██ ██ ██ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ██ ", + " █████ █████ █████ ", + " ", + " ███ █ █████ █████ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearTurkishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ ", + "█ █ █ █ ", + "█ █ █ █ ", + " █ █ █ █ ", + " █ █ █ ", + " █ █ █ ", + " █ ███ █████ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ █ █████ █████ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthTurkishScreen = makeDisplayFrame(arrayOf( + " █ █ █ ", + " █ █ █ █ ", + "█ █ █ █ ", + "█████ █ █ ", + "█ █ █ ", + "█ █ █ ", + "█ █ █ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ █ █████ █████ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayTurkishScreen = makeDisplayFrame(arrayOf( + " ███ █ █ █ █ ", + "█ █ █ █ ", + "█ █ █ ██ █ ", + "█ ███ █ █ █ █ █ ", + "█ █ █ █ █ ██ ", + "█ █ █ █ █ █ ", + " ████ ███ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ █ █████ █████ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ ██ ██ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ ████ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ ███ ███ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourPolishScreen = makeDisplayFrame(arrayOf( + " ███ ███ ███ █████ ███ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ ", + "█ ███ █ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ ███ ███ █████ ███ █ █ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinutePolishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █ ", + "██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █ █ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearPolishScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "████ █ █ ██ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ ███ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthPolishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █████ ████ ███ ███ ███ ", + "██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █████ █ ", + "█ █ █ █ ████ ███ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █████ ████ ███ █ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayPolishScreen = makeDisplayFrame(arrayOf( + "███ █████ ███ █████ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ", + "█ █ █ █ ████ ██ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ ", + "███ █████ ███ █████ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourCzechScreen = makeDisplayFrame(arrayOf( + "█ █ ███ ███ ███ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ ", + "█████ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ ███ ███ █ █ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteCzechScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █ ", + "██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █ █ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearCzechScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "████ █ █ ██ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ ███ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthCzechScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ ████ █ ███ ", + "██ ██ █ █ █ █ █ ", + "█ █ █ █████ █ ███ █ ", + "█ █ █ █ ███ █ █ ", + "█ █ ████ █ █ █ ", + "█ █ █ █ █ █ █ ", + "█ █ █████ ████ ███ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayCzechScreen = makeDisplayFrame(arrayOf( + "███ █████ █ █ ", + "█ █ █ █ █ ", + "█ █ █ ██ █ ", + "█ █ ████ █ █ █ ", + "█ █ █ █ ██ ", + "█ █ █ █ █ ", + "███ █████ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourHungarianScreen = makeDisplayFrame(arrayOf( + " █ ████ █ ", + " █ █ █ █ █ ", + " ███ █ █ █ █ ", + "█ █ ████ █████ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + " ███ █ █ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteHungarianScreen = makeDisplayFrame(arrayOf( + "████ █████ ████ ███ ", + "█ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "████ ████ ████ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █████ █ █ ███ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearHungarianScreen = makeDisplayFrame(arrayOf( + " █ █ █ ", + " █ █ █ ", + "█████ █ █ ", + "█ █ █ ", + "████ █ █ ", + "█ █ █ ", + "█████ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthHungarianScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ ██ █ █ █ █ █ ", + "█████ █ █ █ █ █ █████ ████ ", + "█ █ █ █ █ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ █ █ █ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayHungarianScreen = makeDisplayFrame(arrayOf( + "█ █ █ ████ ", + "█ █ █ █ █ █ ", + "██ █ █ █ █ █ ", + "█ █ █ █████ ████ ", + "█ ██ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourSlovakScreen = makeDisplayFrame(arrayOf( + "█ █ ███ ███ ███ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ ", + "█████ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ ███ ███ █ █ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteSlovakScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █████ █ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █ █ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearSlovakScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "████ █ █ ██ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ ███ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthSlovakScreen = makeDisplayFrame(arrayOf( + "█ █ █████ ████ ███ █ ███ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ █████ ████ ███ █ █ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDaySlovakScreen = makeDisplayFrame(arrayOf( + "███ █████ █ █ ", + "█ █ █ █ ", + "█ █ █ █ █ ", + "█ █ ████ ██ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ ██ ", + "███ █████ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourRomanianScreen = makeDisplayFrame(arrayOf( + " ███ ████ █ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ ████ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █████ ", + " ███ █ █ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteRomanianScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ ", + "██ ██ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearRomanianScreen = makeDisplayFrame(arrayOf( + " █ █ █ ", + " █ █ █ █ ", + "█ █ ██ █ ", + "█████ █ █ █ ", + "█ █ █ ██ ", + "█ █ █ █ ", + "█ █ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthRomanianScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ ", + "█ █ █ █ █ █████ ", + "█████ ███ █ █ █ █ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayRomanianScreen = makeDisplayFrame(arrayOf( + "█████ ███ ", + " █ █ ", + " █ █ ", + " █ █ ", + " █ █ ", + "█ █ ", + "█████ ███ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourCroatianScreen = makeDisplayFrame(arrayOf( + " ████ █ █████ ", + "█ █ █ █ ", + "█ █ █ █ ", + " ███ █████ █ ", + " █ █ █ █ ", + " █ █ █ █ ", + "████ █ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteCroatianScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █████ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ████ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █████ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearCroatianScreen = makeDisplayFrame(arrayOf( + " ███ ███ ███ ███ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ ", + "█ ███ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + " ████ ███ ███ ███ █ █ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthCroatianScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █████ ████ █████ ███ ", + "██ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ ████ ███ ████ █ ", + "█ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ██ █████ ████ █████ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayCroatianScreen = makeDisplayFrame(arrayOf( + "███ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ ██ █ ", + "█ █ █████ █ █ █ ", + "█ █ █ █ █ ██ ", + "█ █ █ █ █ █ ", + "███ █ █ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourDutchScreen = makeDisplayFrame(arrayOf( + "█ █ ████ █████ █ █ ", + "█ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ ", + "█ █ ████ ████ █ █ █ ", + "█ █ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ ", + " ███ █ █ █████ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteDutchScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █████ █ █ ", + "██ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █████ █ █ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearDutchScreen = makeDisplayFrame(arrayOf( + " ███ █ █ ████ ", + " █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █████ █████ ████ ", + " █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthDutchScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ ███ ", + "██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ ", + "█ █ █ █████ █████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayDutchScreen = makeDisplayFrame(arrayOf( + "███ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █████ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "███ █ █ ████ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourGreekScreen = makeDisplayFrame(arrayOf( + " ███ ████ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "█ █ ████ █ █ ", + "█ █ █ █████ ", + " █ █ █ █ █ ", + "██ ██ █ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteGreekScreen = makeDisplayFrame(arrayOf( + " █ █████ █████ █████ ███ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ ███ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearGreekScreen = makeDisplayFrame(arrayOf( + "█████ █████ ███ █████ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "████ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "█████ █ ███ █████ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthGreekScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ █ █████ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ █ ", + "█ █ █████ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █████ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █████ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayGreekScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █████ ████ █ ", + "█ █ ██ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ ████ ████ █ █ ", + "█ █ █ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourFinnishScreen = makeDisplayFrame(arrayOf( + "█████ █ █ █ █ █████ ███ ", + " █ █ █ █ █ █ █ ", + " █ █ █ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " █ █ █ █ ██ █ █ ", + " █ █ █ █ █ █ █ ", + " █ ███ █ █ █ ███ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteFinnishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █ █ █████ █████ ███ ", + "██ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ ███ █ █ ███ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearFinnishScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ ███ ████ ███ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ███ █ ", + "█ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ", + " █ ███ ███ ████ ███ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthFinnishScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ █ █ █ █ █ ████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "██ █ █ █ █ ██ █████ █ █ ███ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ ███ █ █ █ █ ███ ████ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayFinnishScreen = makeDisplayFrame(arrayOf( + "████ █ █ ███ █ █ █ █ ", + "█ █ ███ █ █ █ ███ ", + "█ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ █ █ ", + "█ █████ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ ███ █ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourNorwegianScreen = makeDisplayFrame(arrayOf( + "█████ ███ █ █ █████ ", + " █ █ ██ ██ █ ", + " █ █ █ █ █ █ ", + " █ █ █ █ █ ████ ", + " █ █ █ █ █ ", + " █ █ █ █ █ ", + " █ ███ █ █ █████ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteNorwegianScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █████ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearNorwegianScreen = makeDisplayFrame(arrayOf( + " █ ████ ", + " █ █ █ █ ", + " █ █ █ ", + " █ █ ████ ", + "█ █ █ █ ", + "█████ █ █ ", + "█ █ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthNorwegianScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █████ ███ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ████ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "█ █ █████ █ █ █ █ █ ", + "█ █ █ █ █ █ █████ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayNorwegianScreen = makeDisplayFrame(arrayOf( + "███ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █████ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "███ █ █ ████ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourPortugueseScreen = makeDisplayFrame(arrayOf( + "█ █ ███ ████ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█████ █ █ ████ █████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ █ █ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinutePortugueseScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ ███ ", + "██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ ███ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearPortugueseScreen = makeDisplayFrame(arrayOf( + " █ █ █ ███ ", + " █ █ █ █ █ █ ", + "█ █ ██ █ █ █ ", + "█████ █ █ █ █ █ ", + "█ █ █ ██ █ █ ", + "█ █ █ █ █ █ ", + "█ █ █ █ ███ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthPortugueseScreen = makeDisplayFrame(arrayOf( + "█ █ █ ████ ", + "██ ██ █ █ █ ", + "█ █ █ █████ █ ", + "█ █ █ █ ███ ", + "█ █ ████ █ ", + "█ █ █ █ ", + "█ █ █████ ████ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayPortugueseScreen = makeDisplayFrame(arrayOf( + "███ ███ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █ █████ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "███ ███ █ █ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourSwedishScreen = makeDisplayFrame(arrayOf( + "█████ ███ █ █ █ █ █████ ", + " █ █ ██ ██ ██ ██ █ ", + " █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ████ ", + " █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ ███ █ █ █ █ █████ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteSwedishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ ", + "██ ██ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearSwedishScreen = makeDisplayFrame(arrayOf( + " █ ████ ", + " █ █ █ █ ", + " █ █ █ ", + " █ █ ████ ", + "█ █ █ █ ", + "█████ █ █ ", + "█ █ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthSwedishScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ ███ ", + "██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █████ █ █ ", + "█ █ █ █ █ ██ █ █ █ █ ", + "█ █ █████ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDaySwedishScreen = makeDisplayFrame(arrayOf( + "███ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █████ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "███ █ █ ████ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourDanishScreen = makeDisplayFrame(arrayOf( + "█████ ███ █ █ █████ ", + " █ █ ██ ██ █ ", + " █ █ █ █ █ █ ", + " █ █ █ █ █ ████ ", + " █ █ █ █ █ ", + " █ █ █ █ █ ", + " █ ███ █ █ █████ ", + " ", + " ████ ████ █ █ ", + " █████ ██ ██ █ █ ██ ██ ", + " ██ ██ ██ ██ █ █ █ █ █ ", + " █ █ █ ██ ██ ████ █ █ █ ", + " █ █ ██ ██ █ █ █ ", + "█ █ █ ██ █ █ █ ", + "█ █ ██ ██ █ █ █ ", + "█ ████ ██ ██ ", + "█ ██ ██ ", + "█ ██ ██ ", + " █ ███ ██ ", + " █ ██ ██ ", + " ██ ████ ██ ", + " █████████ ██ ", + " █████ ████████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteDanishScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ ", + "██ ██ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ ", + " ", + " █████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ███ ", + " █ █ █ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ███ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ █████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearDanishScreen = makeDisplayFrame(arrayOf( + " █ ████ ", + " █ █ █ █ ", + " █ █ █ ", + " █ █ ████ ", + "█ █ █ █ ", + "█████ █ █ ", + "█ █ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthDanishScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █████ ███ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ████ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "█ █ █████ █ █ █ █ █ ", + "█ █ █ █ █ █ █████ ███ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayDanishScreen = makeDisplayFrame(arrayOf( + "███ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ █████ █ ███ ", + "█ █ █ █ █ █ ", + "█ █ █ █ █ █ ", + "███ █ █ ████ ", + " ", + " ████ ████████ ", + "█████████████ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " ███ ███ █████ █ ████ █ █ ███ █████ ███ █ █ █████", + "█ █ █ █ ██ █ ██ █ █ ██ ██ █ █ █ █ █ ██ ██ █ ", + "█ ██ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ████ ", + "█ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █", + "██ █ █ ██ █ █████ █ █ █ █ █ ██ █ █████ █ █", + "█ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ █████ ███ █ █ █ █ █████ █ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsHourGermanScreen = makeDisplayFrame(arrayOf( + " ████ █████ █ █ █ █ ███ █████ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + " ███ █ █ █ █ █ █ █ █ ████ ", + " █ █ █ █ █ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ █ ", + "████ █ ███ █ █ ███ █████ ", + " ", + " ██ ████ ", + " █████ ███ ██ ██ ", + " ██ ██ ████ ██ ██ ", + " █ █ █ ██ ██ ██ ", + " █ █ ██ ██ ██ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ██ ██ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + " █ ███ ██ ██ ██ ", + " █ ██ ██ ██ ██ ", + " ██ ████ ██ ██ ██ ", + " █████████ ██ ██ ██ ", + " █████ ██ ████ ", + " ", + " █ ███ ███ ███ ███ █ ███ █ █ █████", + " ██ █ █ ██ █ █ █ █ █ █ ██ █ █ ██ ██ █ ", + " █ █ ██ ██ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █████ █████ █████ ███ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinuteGermanScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █████ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ████ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █████ ", + " ", + " ████ ████ ", + " █████ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " █ █ █ ██ ██ ██ ██ ", + " █ █ ██ ██ ██ ", + "█ █ █ ██ ██ ", + "█ █ ██ ██ ██ ", + "█ ████ ██ ██ ██ ", + "█ ██ ██ ██ ", + "█ ██ ██ ██ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ", + " █████ ████████ ████████ ", + " ", + " █ ███ ███ ███ ███ █ ███ █ █ █████", + " ██ █ █ ██ █ █ █ █ █ █ ██ █ █ ██ ██ █ ", + " █ █ ██ ██ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █████ █████ █████ ███ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsYearGermanScreen = makeDisplayFrame(arrayOf( + " ███ █ █ █ ████ ", + " █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █████ █████ ████ ", + " █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ █ █ ", + " ", + " ████ ████ ██ ███████ ", + "█████████████ ██ ██ ██ ██ ███ ██ ", + "█ ██ ██ ██ ██ ██ ████ ██ ", + "██████████████ ██ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██████ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ██ ██ ", + " █████████████ ██ ██ ██ ██ ██ ", + " ██ ██ ██ ██ ██ ██ ", + " ████████ ████ ██ █████ ", + " ", + " █ ███ ███ ███ ███ █ ███ █ █ █████", + " ██ █ █ ██ █ █ █ █ █ █ ██ █ █ ██ ██ █ ", + " █ █ ██ ██ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █████ █████ █████ ███ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMonthGermanScreen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █████ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █████ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ █ █ █ ", + " ", + " ██ ", + "█████████████ ███ ", + "█ ██ ███ ", + "██████████████ ████ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ █ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ████████ ", + "█ █ █ █ ██████ ██ ", + "██████████████ ██ ", + " █████████████ ██ ", + " ██ ", + " ██ ", + " ", + " █ ███ ███ ███ ███ █ ███ █ █ █████", + " ██ █ █ ██ █ █ █ █ █ █ ██ █ █ ██ ██ █ ", + " █ █ ██ ██ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █████ █████ █████ ███ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsDayGermanScreen = makeDisplayFrame(arrayOf( + "█████ █ ███ ", + " █ █ █ █ █ ", + " █ █ █ █ ", + " █ █████ █ ███ ", + " █ █ █ █ █ ", + " █ █ █ █ █ ", + " █ █ █ ████ ", + " ", + " ████ ██ ", + "█████████████ ██ ██ ███ ", + "█ ██ ██ ██ ████ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ", + "██████████████ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ", + "██████████████ ██ ██ ", + " █████████████ ██ ██ ", + " ██ ██ ", + " ████████ ██ ", + " ", + " █ ███ ███ ███ ███ █ ███ █ █ █████", + " ██ █ █ ██ █ █ █ █ █ █ ██ █ █ ██ ██ █ ", + " █ █ ██ ██ █ █ █ █ █ ██ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █", + " █ ██ █ ██ █ █ █ █ ██ █ █████ █ █", + " █ █ █ ██ █ █ █ █ ██ █ █ █ ██ █ █ █", + " ███ ███ █████ █████ █████ ███ ██ ███ █ ██ ███ ███ ", + " " +)) + +val testUSDateFormatScreen = makeDisplayFrame(arrayOf( + "███ █ █████ █ █ █ █ ████ █████ ███ ████ █ █ █ █████ ", + "█ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ █ █ █ ███ ████ █ █ ████ █ █ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ ███ █ █ ████ █ ███ █ █ █ █ █ █ █ ", + " ", + " ██ ██ █████ ", + "█████████████ ██ ██ ███████ ", + "█ ██ ██ ██ ███ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ", + "██████████████ ██ ██ ███ ", + "█ █ █ █ █ █ ██ ██ ██ █████ ", + "██████████████ ██ ██ █████ ", + "█ █ █ █ █ █ ██ ██ ██ ███ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + " █████████████ ███ ███ ██ ███ ", + " ██████ ███████ ", + " ████ █████ ", + " ", + " ███ ███ ███ █ ████ █ █ ███ ███ ███ █████ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ██ ", + "█ ██ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ █ █ ", + "█ █ █ ███ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "██ █ █ █ ██ █ █ █ █ █ ██ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ███ █████ ███ █ █ █ ███ █████ ███ ███ ███ ███ ", + " " +)) + +val testEUDateFormatScreen = makeDisplayFrame(arrayOf( + "███ █ █████ █ █ █ █ ████ █████ ███ ████ █ █ █ █████ ", + "█ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ █ █ █ ███ ████ █ █ ████ █ █ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ ███ █ █ ████ █ ███ █ █ █ █ █ █ █ ", + " ", + " ████████ ██ ██ ", + "█████████████ ████████ ██ ██ ", + "█ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ █ █ ██ ██████ ██ ██ ", + "██████████████ ██████ ██ ██ ", + "█ █ █ █ █ █ ██ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + "█ █ █ █ ██████ ██ ██ ██ ", + "██████████████ ██ ██ ██ ", + " █████████████ ██ ███ ███ ", + " ████████ ██████ ", + " ████████ ████ ", + " ", + " ███ ███ ███ █ ████ █ █ ███ █████ ███ ███ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ██ ██ ", + "█ ██ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ ██ █ █ █ ", + "█ █ █ ███ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "██ █ █ █ ██ █ █ █ █ █ ██ █ █ ██ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ ", + " ███ ███ █████ ███ █ █ █ ███ ███ ██ ███ █████ ██ ███ ███ ", + " " +)) + +val testTimeAndDateSettingsMinute24Screen = makeDisplayFrame(arrayOf( + "█ █ ███ █ █ █ █ █████ █████ ", + "██ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ████ ", + "█ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █ █████ ", + " ", + " ████ ██ ", + " █████ ██ ██ ███ ", + " ██ ██ ██ ██ ███ ", + " █ █ █ ██ ██ ████ ", + " █ █ ██ ██ █ ██ ", + "█ █ █ ██ ██ ██ ", + "█ █ ██ ██ █ ██ ", + "█ ████ ██ ██ ██ ██ ", + "█ ██ ██ ██ ██ ", + "█ ██ ██ ████████ ", + " █ ███ ██ ██ ", + " █ ██ ██ ██ ", + " ██ ████ ██ ██ ", + " █████████ ██ ██ ", + " █████ ████████ ██ ", + " ", + " ███ ███ ███ █ ███ ███ ███ ██ ███ ███ ", + "█ █ █ █ ██ █ █ ██ █ █ █ █ █ █ █ █ █ █ █", + " █ █ ██ █ █ █ █ █ █ █ ██ █ █ █", + " █ █ █ █ █ █ ████ █ █ █ ████ █ █ ", + " █ █ ██ █ █████ █ █ ██ █ █ █ █ █ ", + " █ █ ██ █ █ █ █ ██ █ █ █ █ ██ █ █ ", + "█████ █████ █████ █ █████ ██ ██ ███ ███ ██ █████ █████", + " " +)) + +val testMyDataTbrDataEnglishScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ███ █ █████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █ █ █████ █ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ ███ █ █ █ █ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ ███ ███ ███ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + "███████ █ ██ █ ██ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ █ █ ███ ███ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ ██ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ ███████ █ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ █ █ ██ ██ █ ██ █ █ █ █ █ ███████ █ █ █ ██ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ ██ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ █████ ███ ██ ███ ███ ", + " " +)) + +val testMyDataErrorDataEnglishScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ███ ████ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ████ ████ █ █ ████ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ █ █ ███ █ █ ███ █ █ █ █ █ ", + " ", + " █ █ █ ██ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + "█████ ████ ████ ███ █ █ █ ███ █████ █ █ █████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █ █████ █ █ █ █ ████ █ █ ████ █ █ ", + " █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ ███ █ █ █ █ ███ █████ █████ █████ █████ ███ ", + " ", + " ███ █ █ ███ ███ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ ██ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ ███████ █ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ █ █ ██ ██ █ ██ █ █ █ █ █ ███████ █ █ █ ██ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ ██ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ █████ ███ ██ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsEnglishScreen = makeDisplayFrame(arrayOf( + "███ █ ███ █ █ █ █████ ███ █████ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ █ █ █ █ █ █████ █ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ ███ █████ █ █ ███ █ █ █ █████ ████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ ███ ██ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ ████ ████ █ █ ", + " █ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "██████ █████ ███ ██ ██ ███ ", + " ", + " ███████ █████ ███ ███ █ ", + " █ █ █ █ █ █ █ ██ ", + " ███████ █ █ ██ █ ██ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ █ ██ █ ██ █ █ ", + " █ █ ███ █ █ █ █ ██ █ █ █ ", + " ███████ ███ ███ ██ ███ ███ ", + " " +)) + +val testMyDataBolusDataEnglishScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ ███ █ █ █ █ █ ", + " ", + " ███ █ ███ █ █ ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ ██ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ██ █ █ █ ██ █ █ █ █ ██ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ", + "██ ████ ███ ██ ███ ███ ███ ███ ███ ███ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ███ ███ ███ █████ █████ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ ████ █ █ █████ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ██ █ █ █ ", + " ███ ███ ██ ███ █ █ █ █ █ ███████ ███ ███ ██ ███ ███ ", + " " +)) + +val testMyDataBolusDataSpanishScreen = makeDisplayFrame(arrayOf( + "███ █ █████ ███ ████ ███ █████ ████ ███ █ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ ███ █ █ ████ ████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ ███ ████ ███ █████ ████ ███ █████ ███ ", + " ", + " ███ █ ███ █ █ ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ ██ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ██ █ █ █ ██ █ █ █ █ ██ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ", + "██ ████ ███ ██ ███ ███ ███ ███ ███ ███ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ███ ███ ███ █████ █████ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ ████ █ █ █████ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ██ █ █ █ ", + " ███ ███ ██ ███ █ █ █ █ █ ███████ ███ ███ ██ ███ ███ ", + " " +)) + +val testMyDataTbrDataSpanishScreen = makeDisplayFrame(arrayOf( + "███ █ █████ ███ ████ ███ █████ ███ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ ███ █ █ ████ █ █ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ ███ ████ ███ █████ ███ ████ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ ███ ███ ███ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + "███████ █ ██ █ ██ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ █ █ ███ ███ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ ██ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ ███████ █ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ █ █ ██ ██ █ ██ █ █ █ █ █ ███████ █ █ █ ██ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ ██ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ █████ ███ ██ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsSpanishScreen = makeDisplayFrame(arrayOf( + "█████ ███ █████ █ █ █████ ████ ███ ███ █ ████ ███ ███ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █████ █ ████ ███ █ █ █ █████ ████ █ █ █ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ███ █ █ █ █████ █████ ████ ███ ███ █ █ █ █ ███ ███ ████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ ███ ██ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ ████ ████ █ █ ", + " █ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "██████ █████ ███ ██ ██ ███ ", + " ", + " ███████ █████ ███ ███ █ ", + " █ █ █ █ █ █ █ ██ ", + " ███████ █ █ ██ █ ██ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ █ ██ █ ██ █ █ ", + " █ █ ███ █ █ █ █ ██ █ █ █ ", + " ███████ ███ ███ ██ ███ ███ ", + " " +)) + +val testMyDataErrorDataSpanishScreen = makeDisplayFrame(arrayOf( + "███ █ █████ ███ ████ ███ █████ █████ ████ ████ ███ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ ███ █ █ ████ ████ ████ ████ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ ███ ████ ███ █████ █████ █ █ █ █ ███ █ █ ", + " ", + " █ █ █ ██ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + "███ ████ █████ ███ █ █ █ ███ █████ █ █ ███ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █████ █ █ █ █ ████ █ █████ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ████ █ ███ █ █ █ █ ███ █████ █████ █ █ ███ █ █ ", + " ", + " ███ █ █ ███ ███ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ ██ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ ███████ █ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ █ █ ██ ██ █ ██ █ █ █ █ █ ███████ █ █ █ ██ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ ██ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ █████ ███ ██ ███ ███ ", + " " +)) + +val testMyDataBolusDataFrenchScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ ", + "█ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ ", + " ", + "███████ █ ███ █ █ ███ █ █████ ███ ", + "█ █ ██ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ██ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ██ █ ██ ███ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ ███ █████ ", + " ██ █ █ █ █ ██ █ █ █ ", + "███████ █ ██ █ ██ ██ █ ██ ████ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ██ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ ██ █ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ██ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataTbrDataFrenchScreen = makeDisplayFrame(arrayOf( + "███ ████ █████ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "█ █ ████ █ ", + "█ █ █ █ █ ", + "█ █ █ █ █ ", + "███ ████ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsFrenchScreen = makeDisplayFrame(arrayOf( + " ███ █ █ █ █ █ █████ ███ █████ █ ████ ███ ███ █ █ ████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ █████ █ █ █ █ █ █ █ █ ██ █ ", + "█ █ █ █ █████ █ █ █ █ █ █ █ ███ █ █ █ █ █ ████ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ ████ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " ██ █ ███ █ █ █ █ █ ███ █ █████ ████ ██ ███ ███ █ █ █ █ ██ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ██ █ █ ", + "█ █ █ █ █ █ ", + " █ █ █ █ █ ", + " █ █ ████ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ ██ █ █ █ █ ", + "██████ █ ██ ███ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataFrenchScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ █████ █ █ ████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "████ ████ ████ ████ █ █ ████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ █ █ █████ ███ █ █ ████ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + "█████ ███ █ █ ███ █ █ ███ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ █ █ █ █ █ ████ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ███ █ █ ███ ███ ███ ████ █ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataTbrDataItalianScreen = makeDisplayFrame(arrayOf( + "█ █ █████ █ █ ███ ████ ███ █ ████ ████ █████ ", + "██ ██ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ █ █ █ █ █ ████ █ █████ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ ███ █ █ ███ █ █ █ ████ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataBolusDataItalianScreen = makeDisplayFrame(arrayOf( + "█ █ █████ █ █ ███ ████ ███ █ ████ ███ █ ███ ", + "██ ██ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ █ █ █ █ █ ████ █ █████ ████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ ███ █ █ ███ █ █ ████ ███ █████ ███ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataDailyTotalsItalianScreen = makeDisplayFrame(arrayOf( + "█████ ███ █████ █ █ ███ ███ ███ ███ ████ █ █ █ █████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + " █ █ █ █ █████ █ █ █ ███ █ █ █ ████ █ █ █ █████ █ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ███ █ █ █ █████ ███ ████ ███ ███ █ █ █ █ █ █ █ █ █ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █ █ ███ █ █ ", + "█ █ ██ ██ █ █ █ █ ", + " █ █ █ █ █ █ ", + " █ █ █ █ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ ███ ██ █████ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataItalianScreen = makeDisplayFrame(arrayOf( + "█ █ █████ █ █ ███ ████ ███ █ █ █ █ █ ████ █ █ ███ ", + "██ ██ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ █ █ █ █ █ ████ █ █████ █████ █ █ █████ ████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ ███ █ █ ███ █ █ █ █ █████ █████ █ █ █ █ █ █ ███ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + "████ ████ █████ █████ █████ ████ █ █ ███ █ █ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + "████ ████ █ █ ████ ████ █ █ █ █ █ █ █ █████ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ████ █ █ █████ █ █ █ █ ███ █ █ █ █ █ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataErrorDataRussianScreen = makeDisplayFrame(arrayOf( + " ██ █ █ █ █ █ █ █ █████ ███ █████ ███ █ █ █ █ █ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █████ ██ █ ████ █ █ ████ █ █ █ █ █ █ █ █ ████ ", + "█ █ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ ██ █ █████ ███ ████ ███ █████ █ █ ████ ██ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + "█ █ ███ █ █ █████ █ █ ██ █████ █ █ ███ █████ ████ █████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "██ █ █ █████ ████ █ █ █ █ ████ █ ██ █ █ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █████ █████ █ ██ █ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ ███ █ █ █████ █ █ █ █████ █ █ ███ █ ██ ████ ████ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataBolusDataRussianScreen = makeDisplayFrame(arrayOf( + " ██ █ █ █ █ █ █ █ █████ ███ █████ ███ ████ █ █ ███ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █████ ██ █ ████ █ █ ████ █ █ █ █ ███ █ █ ████ ", + "█ █ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █████ ███ ████ ███ ██ █ █ █ ███ █████ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataRussianScreen = makeDisplayFrame(arrayOf( + " ██ █ █ █ █ █ █ █ █████ ███ ████ █████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █████ ██ █ ████ █ █ ████ ████ █ ", + "█ █ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █████ ███ ████ ████ ███ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsRussianScreen = makeDisplayFrame(arrayOf( + " ███ █ █ █████ ███ █ █ █ █ █ █ █████ ██ ███ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ███ █ █ █ █ ██ █████ ██ █ ████ █ █ █ █ ██ ██ █ ", + "█ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ ", + " ███ █ █ ███ █ █ █ ██ █ █████ █ █ ███ ███ ██ █ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █ ███ ███ █ █ ", + "█ █ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █ ████ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ █████ ██ ██ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataTurkishScreen = makeDisplayFrame(arrayOf( + " ███ ████ █ █ █ █ █████ ████ █ █ █████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ███ ", + "█ ███ ████ █████ █ █ ████ ████ █ █ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ ████ █ █ █ █████ █ █ ███ █████ █████ █ █ ███ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataErrorDataTurkishScreen = makeDisplayFrame(arrayOf( + "█ █ █ █████ █ █ █ █████ ████ █ █ █████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ███ ", + "█████ █████ █ █████ █ █ ████ ████ █ █ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █████ █ █ ███ █████ █████ █ █ ███ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + " ███ ████ █ █ ████ █ █████ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ███ █ █ ███ ", + "█ ███ ████ █████ ████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ ████ █ █ ████ ███ █ █ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsTurkishScreen = makeDisplayFrame(arrayOf( + " ███ █ █ █ █ █ █ █ █ █ █████ ███ ████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ███ █ █ █ █ █ █ █ █ ██ █ █ █ ████ █ █████ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ████ ███ █ █ █████ ███ █ █ █ ███ █ █████ █ █ █ █ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █ ███ ███ █ █ ", + "█ █ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █ ████ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ █████ ██ ██ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataBolusDataTurkishScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ █ █ █████ ████ █ █ █████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ███ ", + "████ █ █ █ █ █ ███ █ █ ████ ████ █ █ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ █ █████ █ █ ███ █████ █████ █ █ ███ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataPolishScreen = makeDisplayFrame(arrayOf( + "███ █ █ █ █████ █████ ███ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ ████ █ █ █ ████ ", + "█ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ █ █████ █ ███ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataErrorDataPolishScreen = makeDisplayFrame(arrayOf( + "███ █ █ █ █████ ████ █ █████ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ ████ █ █ █ █ ", + "█ █ █████ █ █ █ ████ ████ ██ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ██ █████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ █ █████ ████ ████ ██ ███ ███ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + "█ █ ███ █ █ ███ █████ ███ █████ ███ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "██ █ █ █ █ █ █ ████ █ █ █ █ ████ ", + "█ █ █ █ █ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ ███ █████ ███ █ ███ █ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsPolishScreen = makeDisplayFrame(arrayOf( + "███ █████ ███ █████ █ █ ███ ███ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ████ █ █ █ █ █ █ █████ ██ ██ ", + "█ █ █ █ █ █ ██ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ ██ █ █ █ █ █ █ █ ██ ", + "███ █████ ███ █████ █ █ ██ ███ ██ ███ █ █ ████ █ █ ██ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █ ███ ███ █ █ ", + "█ █ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █ ████ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ █████ ██ ██ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataBolusDataPolishScreen = makeDisplayFrame(arrayOf( + "███ █ █ █ █████ ████ ███ █ █ █ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ ████ ████ █ █ █ █ █ ███ █████ ", + "█ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ █ █████ ████ ███ █████ ███ ████ █ █ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataBolusDataCzechScreen = makeDisplayFrame(arrayOf( + " █ ███ █ ███ █████ ████ ███ █ █ █ ████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █ ████ ████ █ █ █ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ███ █ █ ██ █████ ████ ███ █████ ███ ████ ███ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataCzechScreen = makeDisplayFrame(arrayOf( + " █ ███ █ ███ █████ ███ ████ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █ ████ █ █ ████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ███ █ █ ██ █████ ███ ████ ███ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsCzechScreen = makeDisplayFrame(arrayOf( + " ███ █████ █ █ █ ███ █████ █ █ ███ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ ████ █ ██ █ █ ████ █ █ █ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ █ ██ █ █ █████ █ █ █ █ █ ", + " ███ █████ █████ █ █ ██ ███ █████ █ █ ██ ███ █ █ █ █ █ █ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █ █████ ███ █ █ ", + "█ █ ██ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █ ████ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "██████ ███ ███ ██ ██ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataCzechScreen = makeDisplayFrame(arrayOf( + " █ ███ █ ███ █████ ███ █ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █ ████ █ █████ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ ███ █ █ ██ █████ ███ █ █ █ ████ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + "███ ████ ███ ████ █ █ ███ █ █ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ██ █ ████ █ █ █ █ ", + "█ █ ████ █ █ ███ ██ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ ████ ███ ████ █ █ ███ █ █ ████ ███ █████ █ █ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsHungarianScreen = makeDisplayFrame(arrayOf( + "█ █ █ ████ ███ █████ █████ █ ███ █████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "██ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █████ ████ █ █ ████ █ █ ████ ███ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ███ █ █████ █████ ██ █████ ████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █ █████ ███ █ █ ", + "█ █ ██ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █ ████ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "██████ ███ ███ ██ ██ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataHungarianScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ █ ███ █ █████ ███ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █████ █████ █ █ █████ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ███ █ █ █ ███ █ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataBolusDataHungarianScreen = makeDisplayFrame(arrayOf( + "████ █ █ █ █ ████ █ ███ █ █████ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █████ █ █ █████ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ █ █ ███ █ █ █ ███ █ █ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataHungarianScreen = makeDisplayFrame(arrayOf( + "█ █ ███ ████ █ █ ███ █ █████ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ ████ █████ █████ █ █ █████ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ███ ████ █ █ █ █ ███ █ █ █ ███ █ █ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + "█████ ████ ████ █ █ █ ███ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █████ █ █ ", + " █ ████ ████ █ █ █ █ ███ ████ ", + " █ █ █ █ █ █ █ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █████ ████ █████ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataBolusDataSlovakScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ ███ █ █ █ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █ █ █ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ ", + "████ ███ █████ ███ ████ ███ █ █████ ███ █ █ █ █ █ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataSlovakScreen = makeDisplayFrame(arrayOf( + "███ █ █████ █ ███ ███ █ █ █ █ ████ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █████ █ █ █ █████ █ █ ████ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ ", + "███ █ █ █ █ █ ███ ███ █ █ █ ████ █ █ ███ █ █ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + "███ ████ ███ █ █ █ █ ███ █ █ █ █ █████ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ██ █ ████ █ ██ █ █ ", + "█ █ ████ █ █ █ █ ██ █ █ █ █ █ █ ████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █████ ", + "███ ████ ███ ███ █ █ ███ █ █ ████ █████ █ █ █ █ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataTbrDataSlovakScreen = makeDisplayFrame(arrayOf( + "███ ████ ███ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █████ █ █ █ ", + "███ ████ ███ ███ █ █ █ █ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsSlovakScreen = makeDisplayFrame(arrayOf( + " ████ █ █ █ █████ █ █ ███ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ █ █ █ █ █ █ ██ █ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ ██ █ █ ", + "████ ███ ████ █ █ ███ █ █ █ █ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █ █████ ███ █ █ ", + "█ █ ██ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █ ████ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "██████ ███ ███ ██ ██ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataRomanianScreen = makeDisplayFrame(arrayOf( + "███ █ █████ █████ ████ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ ████ ████ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ █████ █ █ ████ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataBolusDataRomanianScreen = makeDisplayFrame(arrayOf( + "███ █ █████ █████ ████ ███ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ ████ ████ █ █ █ █ █ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ █████ ████ ███ █████ ███ ████ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataDailyTotalsRomanianScreen = makeDisplayFrame(arrayOf( + "█████ ███ █████ █ █ █ █ ████ ███ █████ ███ █ █ █ ███ ███ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █████ █ █ █ ████ █ █ █ █ █ █ █ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ███ █ █ █ █████ ███ █ █ ███ █████ ███ █████ █ █ ███ ███ █████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █ █████ ███ █ █ ", + "█ █ ██ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ █ ████ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "██████ ███ ███ ██ ██ ███ ", + " ", + " ███████ ███ █████ █ ███ ", + " █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ ████ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataRomanianScreen = makeDisplayFrame(arrayOf( + "███ █ █████ █████ █████ ████ ███ █ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ ████ ████ ████ █ █ █████ ████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ █ █████ █████ █ █ ███ █ █ █ █ █████ ", + " ", + " █ █ █ █████ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ █ ███ ███ ███ ███ ", + " ", + "████ ████ █████ █████ ███ █ █ █ █ ███ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "████ ████ █ ████ █ █ █ █ █████ █ █ █ █████ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ ███ █ █ █ █ █████ ███ █████ █ █ █ ", + " ", + " ███ ███ ███ █████ ██ ████ █ █ ███████ ███ █████ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ ████ █ ████ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ██ ███ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataTbrDataCroatianScreen = makeDisplayFrame(arrayOf( + "████ ███ ███ █ ███ ███ ███ ████ ████ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █████ █ █ █ █ ████ ████ █ █ █████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + "█ ███ ███ █ █ ███ ███ ███ █ ████ ███ ██ █ ", + " ", + " █ ███ █ ███ ██ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ █████ █ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █████ ████ █ █ ███████ ███ ██ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ ████ ██ █ ████ █ █ █ █ █ ███████ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsCroatianScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ ████ █ █ █████ ███ █ █ █████ █ █ ███ ███ █████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ████ █ █ █ ████ █ █ █ █ █ ████ █ █ █ █ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + " ███ █ █ ███ █ █ █ █████ ███ █ █ █████ █ ██ ███ ███ █████ █████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ███ █ █ ", + "█ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ", + " █ █ ███ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "██████ ███ ██ ███ ███ ", + " ", + " ███████ ███ ███ █ ███ ", + " █ █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ ██ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataErrorDataCroatianScreen = makeDisplayFrame(arrayOf( + "████ ███ ███ █ ███ ███ ███ ███ ████ █████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ████ █ █ ", + "████ █ █ █ █ █████ █ █ █ █ █ ███ ████ ████ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + "█ ███ ███ █ █ ███ ███ ███ ████ █ █ █████ ████ █ █ ██ ", + " ", + " █ █ █ █ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + " ████ ████ ████ █████ █ █ █ █ ███ █ █ █ █ ███ ████ █ █ ███ █ ", + "█ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ████ ████ ████ █ █ █ █ █ █ █ ██ █ █ █ █ ███ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ █ █ █ █ ", + "████ █ █ █ █████ █ █ █ █ ███ █ █ █ █ █ ███ ████ ██ █ █ ███ █████ ", + " ", + " ███ ███ █ ███ ██ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataBolusDataCroatianScreen = makeDisplayFrame(arrayOf( + "████ ███ ███ █ ███ ███ ███ ████ ███ █ █ █ ████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █████ █ █ █ █ ████ █ █ █ █ █ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ███ ███ █ █ ███ ███ ███ ████ ███ █████ ███ ████ ███ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataDutchScreen = makeDisplayFrame(arrayOf( + "█████ ████ ███ ███ █████ ███ █████ █ █ █████ █ █ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ", + " █ ████ █ █ █████ █ ███ ████ █ ███ ████ █ █ ████ █ █ █ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ███ ████ █████ ████ █████ █ █████ █ █ ████ ", + " ", + " █ ███ █ ███ ██ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ █████ █ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █████ ████ █ █ ███████ ███ ██ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ ████ ██ █ ████ █ █ █ █ █ ███████ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataBolusDataDutchScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ ███ █████ ███ █████ █ █ █████ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ", + "████ █ █ █ █ █ ███ █ ███ ████ █ ███ ████ █ █ ████ █ █ █ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ ████ █████ ████ █████ █ █████ █ █ ████ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataDutchScreen = makeDisplayFrame(arrayOf( + "█████ ███ █ █ █████ █████ █ █ ███ █████ ███ █████ █ █ █████ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ██ █ █ ", + "████ █ █ █ █ █ ████ █ █ █ █ ███ ████ █ ███ ████ █ █ ████ █ █ █ ███ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ ███ ███ █ █████ █ █ ████ █████ ████ █████ █ █████ █ █ ████ ", + " ", + " █ █ █ █ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + " █ █ █ ████ █ █ █ ████ ███ ███ █ █ █ █ █████ █████ ███ ", + " █ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + "█████ █ █ █ ████ █ █ █ ████ █ █ █ █ █ █████ █ ████ ████ █ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ███ █████ ████ ███ ██ █ █ █ █ █████ █████ █████ ████ ", + " ", + " ███ ███ █ ███ ██ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsDutchScreen = makeDisplayFrame(arrayOf( + "███ █ ███ █████ ███ █████ █ █ █████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "█ █ █████ █ ███ █ █ █ █ █████ █ ████ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ ████ █ ███ █ █ █ █████ █████ █ █ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ███ █ █ ", + "█ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ", + " █ █ ███ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "██████ ███ ██ ███ ███ ", + " ", + " ███████ ███ ███ █ ███ ", + " █ █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ ██ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataTbrDataGreekScreen = makeDisplayFrame(arrayOf( + " █ █████ █ ███ █ █ █████ █ █ █ █████ ████ ████ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + " █ █ ████ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ ████ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ ██ █████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ ██ █ ██ ", + "█████ █████ █████ ███ █ █ █████ █ █ █ █ █ █ ██ ████ ██ █ ██ ", + " ", + " █ ███ █ ███ ██ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ █████ █ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █████ ████ █ █ ███████ ███ ██ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ ████ ██ █ ████ █ █ █ █ █ ███████ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataBolusDataGreekScreen = makeDisplayFrame(arrayOf( + " █ █████ █ ███ █ █ █████ █ █ █ █ ███ █████ █████ ███ █ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ █ ████ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ██ █████ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █████ █████ ███ █ █ █████ █ █ █ █ █████ ███ █████ █████ ██ ██ █ █ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataGreekScreen = makeDisplayFrame(arrayOf( + " █ █████ █ ███ █ █ █████ █ █ █ █ █ █ █████ ███ █ █ ", + " █ █ █ █ █ ██ ██ █ ███ █ █ █ █ ██ ██ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █████ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ ██ █ ███ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █████ █████ ███ █ █ ██ █████ █ █ █ █ █ █ █ █ █ █ ██ ██ █ █ ", + " ", + " █ █ █ █ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + "█ █ █ █ █ █████ █████ █ ███ █ █ █ █ █ █ █ █████ ", + "█ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ ██ ██ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ ", + " █ █ █████ █ █ █ █ █████ █ █ █ █ █ █ █████ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ █ ██ █████ █ █ █ ███ █ █ █ █ █ █ █ █ █ █ ██ ", + " ", + " ███ ███ █ ███ ██ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsGreekScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █████ ████ █ █ █████ ███ ███ █████ █ █ █ █ ███ █ ███ ", + "█ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + "█████ █ █ ████ ████ █████ █ █ █ █ █ ███ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █ █ █ █████ ███ ███ █████ █ █ █ ███ █ █ ███ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ███ █ █ ", + "█ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ", + " █ █ ███ █ █ ", + " █ █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ █ ", + "██████ ███ ██ ███ ███ ", + " ", + " ███████ ███ ███ █ ███ ", + " █ █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ ██ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsFinnishScreen = makeDisplayFrame(arrayOf( + "████ █ █ ███ █ █ █ █ ███ █ █ █ █ █ █ █ ███ ████ ", + "█ █ ███ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ██ █ █ █ █ ", + "████ █ █ █ █ █ ██ █ █ ██ █████ █ █ █ █ █ █ █ █ ███ ", + "█ █████ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ██ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ███ █ ██ █ █ ███ █ █ ██ █ █ █ █ █ █ ███ ████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ███ █ █ ", + "█ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ", + " █ █ ████ █ █ ", + " █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ ██ ██ ███ ", + " ", + " ███████ ███ ███ █ ███ ", + " █ █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ ██ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataBolusDataFinnishScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ █████ ███ █████ ███ ███ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █ █ ████ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ █ ███ █████ ███ ███ █ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataFinnishScreen = makeDisplayFrame(arrayOf( + "█ █ █ █ █ █ █ █████ █ █ ████ █████ ███ █████ ███ ███ █████ ", + "█ █ ███ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ █ █ █ █ █ █ ███ █ █ ████ █ █ █ █ █ ", + "█ █ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █████ █ █ █ ████ █ ███ █████ ███ ███ █ ", + " ", + " █ █ █ █ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + " ███ █ █ ████ █ ███ ████ ████ █ █ █ █ █ ████ ████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ", + " █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ ███ █ █ █ ████ ████ █ █ █ █ █ █████ ███ ███ █████ ", + " █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ████ ██ █████ ███ █ █ ███ █ █ █ █ ████ ████ █ █ ", + " ", + " ███ ███ █ ███ ██ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataTbrDataFinnishScreen = makeDisplayFrame(arrayOf( + "█████ ████ █ █████ ███ █████ ███ ███ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █████ █████ █ █ ████ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ ███ █████ ███ ███ █ ", + " ", + " █ ███ █ ███ ██ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ █████ █ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █████ ████ █ █ ███████ ███ ██ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ ████ ██ █ ████ █ █ █ █ █ ███████ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsNorwegianScreen = makeDisplayFrame(arrayOf( + "███ █ ███ █ █ █ █ █████ █ █ ███ ███ █████ ", + "█ █ ███ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ █ █ ███ █ █ █ █ █ █ ████ █ █ █ █ ███ █ █ ████ ", + "█ █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ ███ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ ████ █ █ █ █ █████ █ █ ████ ███ █████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ███ █ █ ", + "█ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ", + " █ █ ████ █ █ ", + " █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ ██ ██ ███ ", + " ", + " ███████ ███ ███ █ ███ ", + " █ █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ ██ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataTbrDataNorwegianScreen = makeDisplayFrame(arrayOf( + "█ █ ████ ███ ███ █ █████ █ ", + "██ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ █ █ █████ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ ███ ███ █ █ █ █ █ ", + " ", + " █ ███ █ ███ ██ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ █████ █ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █████ ████ █ █ ███████ ███ ██ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ ████ ██ █ ████ █ █ █ █ █ ███████ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataBolusDataNorwegianScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ ███ █ █ █ █ █ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataErrorDataNorwegianScreen = makeDisplayFrame(arrayOf( + "█████ █████ ███ █ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "████ ████ █ █ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █████ ███ █████ ███ █ █ █ █ █ ", + " ", + " █ █ █ █ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + " █ █ █ ████ █ █ █ █ █████ █ █ ███ █ █ █ █ █ █████ ", + " █ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ █ ████ █ █ █ █ ████ █ █ █ █ █ █ █████ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ ", + "█ █ █ █ █ ███ █████ █████ █████ █ ███ █████ ██ █████ █ █ █ █ ", + " ", + " ███ ███ █ ███ ██ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataBolusDataPortugueseScreen = makeDisplayFrame(arrayOf( + "███ █ ███ ███ ████ ███ █████ ████ ███ █ █ █ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ █ ███ █ █ ████ ████ █ █ █ █ █ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ ███ ███ ████ ███ █████ ████ ███ █████ ███ ████ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataPortugueseScreen = makeDisplayFrame(arrayOf( + "███ █ ███ ███ ████ ███ ████ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ █ ███ █ █ ████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ ███ ███ ████ ███ ████ █ ", + " ", + " █ ███ █ ███ ██ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ █████ █ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █████ ████ █ █ ███████ ███ ██ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ ████ ██ █ ████ █ █ █ █ █ ███████ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsPortugueseScreen = makeDisplayFrame(arrayOf( + "█████ ███ █████ █ ███ ████ ███ ███ █ ████ ███ ███ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ ", + " █ █ █ █ █████ █ ███ █ █ █ █ █ ████ █ █ █ ███ ", + " █ █ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ███ █ █ █ ███ ████ ███ ███ █ █ █ █ ███ ███ ████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ███ █ █ ", + "█ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ", + " █ █ ████ █ █ ", + " █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ ██ ██ ███ ", + " ", + " ███████ ███ ███ █ ███ ", + " █ █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ ██ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataErrorDataPortugueseScreen = makeDisplayFrame(arrayOf( + "███ █ ███ ███ ████ ███ █████ █████ ████ ████ ███ ████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ █ █ █ ███ █ █ ████ ████ ████ ████ █ █ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ ███ ███ ████ ███ █████ █████ █ █ █ █ ███ ████ ", + " ", + " █ █ █ █ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + " ███ █ ████ █████ ███ █ █ █ ████ █████ █ █ █ █████ ███ ███ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █████ ████ █ █ █ █ █ █████ ███ ████ █ █ █████ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ █ █ █ ██ ██ █ ███ █ █ ████ █████ █ █ █ █████ ███ ███ ", + " ", + " ███ ███ █ ███ ██ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataBolusDataSwedishScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ ███ █ █ █ █ █ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataSwedishScreen = makeDisplayFrame(arrayOf( + "█████ ████ ███ ███ █ █████ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ █████ █ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ███ ███ █ █ █ █ █ ", + " ", + " █ ███ █ ███ ██ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ █████ █ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █████ ████ █ █ ███████ ███ ██ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ ████ ██ █ ████ █ █ █ █ █ ███████ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsSwedishScreen = makeDisplayFrame(arrayOf( + "███ █ █ ███ █ █ ████ █ █ ███ ████ █████ ███ ████ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ███ █ █ █ ███ █████ █ ███ █ █ █ ████ █ ██ ", + "█ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ ████ █ █ ████ █ █ ███ ████ █ ███ █ █ ███ █ █ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ███ █ █ ", + "█ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ", + " █ █ ████ █ █ ", + " █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ ██ ██ ███ ", + " ", + " ███████ ███ ███ █ ███ ", + " █ █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ ██ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataErrorDataSwedishScreen = makeDisplayFrame(arrayOf( + "█████ █████ █ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "████ ████ █ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ", + "█ █████ █████ ███ █ █ █ █ █ ", + " ", + " █ █ █ █ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + "█ █ ███ █ █ █ ████ █ █ █ █ █ █ ███ █ █ █ ", + "█ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ ", + "█ █ █ █ ███ █████ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ ", + "█ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █████ ", + "█████ █ █ ████ █ █ █ █ █ ███ █████ █████ █ █ ███ █ █ █ ", + " ", + " ███ ███ █ ███ ██ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataBolusDataDanishScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ █ █ █ █ █ ███ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ ███ █ █ █ █ █ ", + " ", + "███ ███ █████ █ █ ███ █ █████ ███ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ ", + "█ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ ██████ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ ██ █ ███ ███ ███ ███ ███ ", + " ", + " █ ███ ███ █ █████ ", + " ██ █ █ █ █ ██ ██ █ ", + "███████ █ ██ █ ██ ██ █ █ ", + "████████ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ █ █ ", + " ██ █ █ █ █ ██ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █ █████ ███ ████ █ █ ███████ ███ █████ █ ███ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ █ █ ", + "█ █ █ █ ██ █ █ ██ █ █ ██ █ █ █ █ █ ███████ █ ██ ████ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █████ ██ █ ██ █ █ █ █ ███████ ██ █ █ █ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ █ ███ ███ █ █ █ ███████ ███ ███ ███ █████ ", + " " +)) + +val testMyDataTbrDataDanishScreen = makeDisplayFrame(arrayOf( + "█ █ ████ ████ ███ █ █████ █ ", + "██ ██ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ ████ ████ █████ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ████ █ █ ███ █ █ █ █ █ ", + " ", + " █ ███ █ ███ ██ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █████ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ █████ █ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ █ ███ ███ ", + " ██ █ █ ██ ██ █ █ █ █ ", + "███████ █ ██ █ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ █ ██ ██ █ ██ █ ", + " ██ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ ███ █████ ███ █████ ████ █ █ ███████ ███ ██ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ ██ ████ ██ █ ████ █ █ █ █ █ ███████ █ ██ █ █ █ █ ", + "█ ██ █ █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ █ ████ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ ███████ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ █████ ███ █ █ █ ███████ ███ ███ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsDanishScreen = makeDisplayFrame(arrayOf( + "███ █ ███ █ ███ ███ █████ ███ █████ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █████ █ ███ █ █ █ ███ █ █ █ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███ █ █ ████ █████ ███ ████ █ ███ █ █ █ █████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ █████ ███ █ █ ", + "█ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ", + " █ █ ████ █ █ ", + " █ █ █ █ █ ", + "█ █ █ █ ██ █ █ █ ", + "██████ ███ ██ ██ ███ ", + " ", + " ███████ ███ ███ █ ███ ", + " █ █ █ █ █ █ █ ██ █ █ ", + " ███████ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ ██ █ █ █ █ ██ █ ", + " █ █ ███ █ █ █ █ █ █ █ ", + " ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataErrorDataDanishScreen = makeDisplayFrame(arrayOf( + "█████ █████ ███ █ ███ █ █████ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "████ ████ █ █ █ █ █████ █ █████ ", + "█ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █████ ██ █████ ███ █ █ █ █ █ ", + " ", + " █ █ █ █ ███ █ █████ ███ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + " █ █ █ ████ █ █ █ ████ █ █ █ ████ █████ █████ ███ █ █ ", + " █ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█████ █ █ █ ████ █ █ █ ███ █ █ █ █████ ████ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ ███ █████ ████ █ █ █ █ █ █ █ █ ███ █ █ ", + " ", + " ███ ███ █ ███ ██ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ ██ ██ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ ████ █████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ ██ █ █ ██ ██ █ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ ███ █████ ███ ███ ", + " " +)) + +val testMyDataTbrDataGermanScreen = makeDisplayFrame(arrayOf( + "█████ ████ ████ ███ █ █ █████ ███ ████ █ █ █ █████ ███ ███ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + " █ ████ ████ █████ █ █ █ █ ████ █ █ ████ █ █ █ █████ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ ███ █ █ █ ███ █ █ █ █ █ █ █ ███ ███ █ █ ", + " ", + " █ █ █ ███ ██ ███ █ █████ ███ ", + " ███ ██ ██ █ █ ██ █ █ █ ██ █ █ █ █ ", + " █████ █ █ █ ██ █ █ ██ █ █ █ █ ██ ", + "███████ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███ █ █ ██ █ █ ██ █ █ █ █ ██ █ ", + " ███ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ██ ███ ███ ███ ███ ", + " ", + " █ ███ ███ ███ ███ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + "███████ █ ██ █ ██ ██ █ ██ █ ██ ", + "████████ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ ██ █ ██ █ ██ ██ █ ██ █ ", + " ██ █ █ █ █ ██ █ █ █ █ ", + " █ ███ ███ ███ ███ ", + " ", + " ███ █ █ ███ ███ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ ██ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ ███████ █ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ █ █ ██ ██ █ ██ █ █ █ █ █ ███████ █ █ █ ██ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ ██ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ █████ ███ ██ ███ ███ ", + " " +)) + +val testMyDataErrorDataGermanScreen = makeDisplayFrame(arrayOf( + "█████ █████ █ █ █ █████ ████ █ █ █████ █ ███ █ █ █ █ ███ █████ █ █ ", + "█ █ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ██ █ ", + "████ ████ █████ █ ████ ████ █ █ █ ████ █ █ █ █ █ █ █ █ █ ███ ████ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "█ █████ █ █ █████ █████ █ █ █ █ █████ █████ ███ ███ █ █ ████ █████ █ █ ", + " ", + " █ █ █ ██ ███ █ █████ ███ ", + " ███ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ ████ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "███████ █ █ ███ ███ ███ ███ ███ ", + " ", + "█████ ████ ████ █ ████ ████ ████ █ █ ███ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ ████ █████ ████ ████ ████ █ █ █ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ ████ █ █ █ █ ████ ████ █ █ ███ ███ █ █ ", + " ", + " ███ █ █ ███ ███ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ ██ ██ ██ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ ██ █ ██ █ ██ █ █ █ █ █ ███████ █ █ █ █ ██ █ ", + "█ ██ █ █ █ █ █ █ █ █ █ █████ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ █ █ ██ ██ █ ██ █ █ █ █ █ ███████ █ █ █ ██ █ █ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ ██ █ █ █ ", + " ███ ███ ███ ███ ███ █ █ █ █ ███████ █████ ███ ██ ███ ███ ", + " " +)) + +val testMyDataDailyTotalsGermanScreen = makeDisplayFrame(arrayOf( + "█████ █ ███ █████ ████ ███ █████ ████ █ █ █ █████ █ █ █████ █ █ ███ █████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ █ ██ ██ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ", + " █ █████ █ ███ ████ ███ █ ███ ████ ███ █████ █ █ █ █ █ █ █ ████ █ █ █ █ ███ ████ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ █ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ████ █████ ████ ████ █████ ████ █ █ █ █ █ █ █ █████ █ █ ████ █████ ", + " ", + " ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ ", + " █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ ", + " ██ █ █ █ █ ██ █ ", + " █ █ █ █ █ █ █ █ ", + " ███ ███ ███ ███ ", + " ", + "██████ ███ ██ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ ", + " █ █ █ █ █ █ █ ", + " █ █ ████ ████ █ █ ", + " █ █ █ █ █ █ █ ", + "█ █ █ █ █ ██ █ █ █ ", + "██████ █████ ███ ██ ██ ███ ", + " ", + " ███████ █████ ███ ███ █ ", + " █ █ █ █ █ █ █ ██ ", + " ███████ █ █ ██ █ ██ █ ", + " █ █ █ █ █ █ █ █ █ █ █ █ ", + " ███████ █ ██ █ ██ █ █ ", + " █ █ ███ █ █ █ █ ██ █ █ █ ", + " ███████ ███ ███ ██ ███ ███ ", + " " +)) + +val testMyDataBolusDataGermanScreen = makeDisplayFrame(arrayOf( + "████ ███ █ █ █ ████ ███ █ █ █████ ███ ████ █ █ █ █████ ███ ███ █ █ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ █ ", + "████ █ █ █ █ █ ███ █ █ █ █ ████ █ █ ████ █ █ █ █████ █ █ █ █ █ █ █ ", + "█ █ █ █ █ █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + "████ ███ █████ ███ ████ ███ █ █ █ ███ █ █ █ █ █ █ █ ███ ███ █ █ ", + " ", + " ███ █ ███ █ █ ███ █ █████ ███ ", + " █ █ ██ █ █ █ █ █ █ ██ █ █ █ █ ", + " █ █ █ █ ██ █ █ █ ██ █ █ █ █ ██ ", + " █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ", + " █ █ █ ██ █ █ █ ██ █ █ █ █ ██ █ ", + " █ █ █ ██ █ █ █ █ █ █ █ █ █ █ █ █ ", + "██ ████ ███ ██ ███ ███ ███ ███ ███ ███ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ███ ███ ███ █████ █████ █ █ █ ███████ ███ ███ ███ █ ", + " █ █ █ █ █ █ █ ██ █ █ █ █ ██ ██ █ █ █ █ █ █ █ █ ██ ", + "█ █ █ █ ██ █ █ ██ ████ █ █ █ █ █ █ ███████ █ ██ █ █ █ ██ █ ", + "█ ██ █ █ █ █ ████ █ █ █████ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ", + "█ █ ██ █ █ ██ █ █ █ █ █ █ ███████ ██ █ █ █ ██ █ █ ", + " █ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ ██ █ █ █ ", + " ███ ███ ██ ███ █ █ █ █ █ ███████ ███ ███ ██ ███ ███ ", + " " +)) diff --git a/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/TokenizationTest.kt b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/TokenizationTest.kt new file mode 100644 index 0000000000..964bb2c8f8 --- /dev/null +++ b/pump/combov2/comboctl/src/jvmTest/kotlin/info/nightscout/comboctl/parser/TokenizationTest.kt @@ -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) + } +} diff --git a/pump/combov2/src/main/AndroidManifest.xml b/pump/combov2/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..3e1c4bb4b6 --- /dev/null +++ b/pump/combov2/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2ActivitiesModule.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2ActivitiesModule.kt new file mode 100644 index 0000000000..2d7595b6dc --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2ActivitiesModule.kt @@ -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 +} diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2Module.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2Module.kt new file mode 100644 index 0000000000..43c26351ce --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/combov2/di/ComboV2Module.kt @@ -0,0 +1,8 @@ +package info.nightscout.androidaps.combov2.di + +import dagger.Module + +@Module(includes = [ + ComboV2ActivitiesModule::class +]) +open class ComboV2Module diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/AAPSComboCtlLogger.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/AAPSComboCtlLogger.kt new file mode 100644 index 0000000000..49750cc3fb --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/AAPSComboCtlLogger.kt @@ -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) + } + } +} diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Fragment.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Fragment.kt new file mode 100644 index 0000000000..442f7a9845 --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Fragment.kt @@ -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 + ) + } +} diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Plugin.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Plugin.kt new file mode 100644 index 0000000000..9a262ca51b --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2Plugin.kt @@ -0,0 +1,2074 @@ +package info.nightscout.androidaps.plugins.pump.combov2 + +import android.content.Context +import android.content.Intent +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreference +import dagger.android.HasAndroidInjector +import info.nightscout.androidaps.combov2.R +import info.nightscout.androidaps.extensions.convertedToAbsolute +import info.nightscout.androidaps.extensions.plannedRemainingMinutes +import info.nightscout.androidaps.extensions.toStringFull +import info.nightscout.androidaps.plugins.general.overview.events.EventDismissNotification +import info.nightscout.androidaps.plugins.general.overview.events.EventNewNotification +import info.nightscout.androidaps.utils.DecimalFormatter +import info.nightscout.comboctl.android.AndroidBluetoothInterface +import info.nightscout.comboctl.base.BasicProgressStage +import info.nightscout.comboctl.base.BluetoothAddress as ComboCtlBluetoothAddress +import info.nightscout.comboctl.base.BluetoothException +import info.nightscout.comboctl.base.ComboException +import info.nightscout.comboctl.base.DisplayFrame +import info.nightscout.comboctl.base.Logger as ComboCtlLogger +import info.nightscout.comboctl.base.LogLevel as ComboCtlLogLevel +import info.nightscout.comboctl.base.NullDisplayFrame +import info.nightscout.comboctl.base.PairingPIN +import info.nightscout.comboctl.base.Tbr as ComboCtlTbr +import info.nightscout.comboctl.main.BasalProfile +import info.nightscout.comboctl.main.Pump as ComboCtlPump +import info.nightscout.comboctl.main.PumpManager as ComboCtlPumpManager +import info.nightscout.comboctl.main.QuantityNotChangingException +import info.nightscout.comboctl.main.RTCommandProgressStage +import info.nightscout.comboctl.parser.AlertScreenContent +import info.nightscout.comboctl.parser.AlertScreenException +import info.nightscout.comboctl.parser.BatteryState +import info.nightscout.comboctl.parser.ReservoirState +import info.nightscout.core.ui.dialogs.OKDialog +import info.nightscout.core.ui.toast.ToastUtils +import info.nightscout.interfaces.constraints.Constraint +import info.nightscout.interfaces.constraints.Constraints +import info.nightscout.interfaces.notifications.Notification +import info.nightscout.interfaces.plugin.PluginDescription +import info.nightscout.interfaces.plugin.PluginType +import info.nightscout.interfaces.profile.Profile +import info.nightscout.interfaces.profile.ProfileFunction +import info.nightscout.interfaces.pump.defs.ManufacturerType +import info.nightscout.interfaces.pump.defs.PumpDescription +import info.nightscout.interfaces.pump.defs.PumpType +import info.nightscout.interfaces.pump.DetailedBolusInfo +import info.nightscout.interfaces.pump.Pump +import info.nightscout.interfaces.pump.PumpEnactResult +import info.nightscout.interfaces.pump.PumpPluginBase +import info.nightscout.interfaces.pump.PumpSync +import info.nightscout.interfaces.queue.CommandQueue +import info.nightscout.interfaces.utils.TimeChangeType +import info.nightscout.rx.bus.RxBus +import info.nightscout.rx.events.EventOverviewBolusProgress +import info.nightscout.rx.events.EventOverviewBolusProgress.Treatment +import info.nightscout.rx.events.EventPumpStatusChanged +import info.nightscout.rx.events.EventRefreshOverview +import info.nightscout.rx.logging.AAPSLogger +import info.nightscout.rx.logging.LTag +import info.nightscout.shared.interfaces.ResourceHelper +import info.nightscout.shared.sharedPreferences.SP +import info.nightscout.shared.utils.DateUtil +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.SupervisorJob +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.joda.time.DateTime +import org.json.JSONException +import org.json.JSONObject + +@Singleton +class ComboV2Plugin @Inject constructor ( + injector: HasAndroidInjector, + aapsLogger: AAPSLogger, + rh: ResourceHelper, + commandQueue: CommandQueue, + private val context: Context, + private val rxBus: RxBus, + private val constraintChecker: Constraints, + private val profileFunction: ProfileFunction, + private val sp: SP, + private val pumpSync: PumpSync, + private val dateUtil: DateUtil +) : + PumpPluginBase( + PluginDescription() + .mainType(PluginType.PUMP) + .fragmentClass(ComboV2Fragment::class.java.name) + .pluginIcon(R.drawable.ic_combov2) + .pluginName(R.string.combov2_plugin_name) + .shortName(R.string.combov2_plugin_shortname) + .description(R.string.combov2_plugin_description) + .preferencesId(R.xml.pref_combov2), + injector, + aapsLogger, + rh, + commandQueue + ), + Pump, + Constraints { + + // Coroutine scope and the associated job. All coroutines + // that are started in this plugin are part of this scope. + private val pumpCoroutineMainJob = SupervisorJob() + private val pumpCoroutineScope = CoroutineScope(Dispatchers.Default + pumpCoroutineMainJob) + + private val _pumpDescription = PumpDescription() + + private val pumpStateStore = SPPumpStateStore(sp) + + // These are initialized in onStart() and torn down in onStop(). + private var bluetoothInterface: AndroidBluetoothInterface? = null + private var pumpManager: ComboCtlPumpManager? = null + + // These are initialized in connect() and torn down in disconnect(). + private var pump: ComboCtlPump? = null + private var connectionSetupJob: Job? = null + private var stateAndStatusFlowsDeferred: Deferred? = null + private var pumpUIFlowsDeferred: Deferred? = null + + // States for the Pump interface and for the UI. + private var pumpStatus: ComboCtlPump.Status? = null + private var lastConnectionTimestamp = 0L + private var lastComboAlert: AlertScreenContent? = null + + // Set to true if a disconnect request came in while the driver + // was in the Connecting, CheckingPump, or ExecutingCommand + // state (in other words, while isBusy() was returning true). + private var disconnectRequestPending = false + + // The current driver state. We use a StateFlow here to + // allow other components to react to state changes. + private val _driverStateFlow = MutableStateFlow(DriverState.NotInitialized) + + // The basal profile that is set to be the pump's current profile. + // If the pump's actual basal profile deviates from this, it is + // overwritten. This check is performed in checkBasalProfile(). + // In setNewBasalProfile(), this value is changed. + private var activeBasalProfile: BasalProfile? = null + // This is used for checking that the correct basal profile is + // active in the Combo. If not, loop invocation is disallowed. + // This is _not_ reset by disconect(). That's on purpose; it + // is read by isLoopInvocationAllowed(), which is called even + // if the pump is not connected. + private var lastActiveBasalProfileNumber: Int? = null + + private var bolusJob: Job? = null + + /*** Public functions and base class & interface overrides ***/ + + sealed class DriverState(val label: String) { + // Initial state when the driver is created. + object NotInitialized : DriverState("notInitialized") + // Driver is disconnected from the pump, or no pump + // is currently paired. In onStart() the driver state + // changes from NotInitialized to this. + object Disconnected : DriverState("disconnected") + // Driver is currently connecting to the pump. isBusy() + // will return true in this state. + object Connecting : DriverState("connecting") + // Driver is running checks on the pump, like verifying + // that the basal rate is OK, checking for any bolus + // and TBR activity that AAPS doesn't know about etc. + // isBusy() will return true in this state. + object CheckingPump : DriverState("checkingPump") + // Driver is connected and ready to execute commands. + object Ready : DriverState("ready") + // Driver is connected, but pump is suspended and + // cannot currently execute commands. This state is + // special in that it technically persists even after + // disconnecting. However, it is still important to + // model it as a driver state to prevent commands + // that deliver insulin from being executed (and, + // it is needed for the isSuspended() implementation). + // NOTE: Instead of comparing the driverStateFlow + // value with this state directly, consider using + // isSuspended() instead, since it is based on the + // driverStateUIFlow, and thus retains the Suspended + // and Error states even after disconnecting. + object Suspended : DriverState("suspended") + // Driver is currently executing a command. + // isBusy() will return true in this state. + class ExecutingCommand(val description: ComboCtlPump.CommandDescription) : DriverState("executingCommand") + object Error : DriverState("error") + } + + val driverStateFlow = _driverStateFlow.asStateFlow() + + // Used by ComboV2PairingActivity to launch its own + // custom activities that have a result. + var customDiscoveryActivityStartCallback: ((intent: Intent) -> Unit)? + set(value) { bluetoothInterface?.customDiscoveryActivityStartCallback = value } + get() = bluetoothInterface?.customDiscoveryActivityStartCallback + + init { + ComboCtlLogger.backend = AAPSComboCtlLogger(aapsLogger) + updateComboCtlLogLevel() + + _pumpDescription.fillFor(PumpType.ACCU_CHEK_COMBO) + } + + override fun onStart() { + super.onStart() + + aapsLogger.debug(LTag.PUMP, "Creating bluetooth interface") + bluetoothInterface = AndroidBluetoothInterface(context) + + aapsLogger.debug(LTag.PUMP, "Setting up bluetooth interface") + bluetoothInterface!!.setup() + + aapsLogger.debug(LTag.PUMP, "Setting up pump manager") + pumpManager = ComboCtlPumpManager(bluetoothInterface!!, pumpStateStore) + pumpManager!!.setup { + _pairedStateUIFlow.value = false + } + + // UI flows that must have defined values right + // at start are initialized here. + + // The paired state UI flow is special in that it is also + // used as the backing store for the isPaired() function, + // so setting up that UI state flow equals updating that + // paired state. + val paired = pumpManager!!.getPairedPumpAddresses().isNotEmpty() + _pairedStateUIFlow.value = paired + + setDriverState(DriverState.Disconnected) + } + + override fun onStop() { + pumpCoroutineScope.cancel() + + runBlocking { + // Normally this should not happen, but to be safe, + // make sure any running pump instance is disconnected. + pump?.disconnect() + pump = null + } + + pumpManager = null + bluetoothInterface?.teardown() + bluetoothInterface = null + + setDriverState(DriverState.NotInitialized) + + super.onStop() + } + + override fun preprocessPreferences(preferenceFragment: PreferenceFragmentCompat) { + super.preprocessPreferences(preferenceFragment) + + val verboseLoggingPreference = preferenceFragment.findPreference(rh.gs(R.string.key_combov2_verbose_logging)) + verboseLoggingPreference?.setOnPreferenceChangeListener { _, newValue -> + updateComboCtlLogLevel(newValue as Boolean) + true + } + + val unpairPumpPreference: Preference? = preferenceFragment.findPreference(rh.gs(R.string.key_combov2_unpair_pump)) + unpairPumpPreference?.setOnPreferenceClickListener { + preferenceFragment.context?.let { ctx -> + OKDialog.showConfirmation(ctx, "Confirm pump unpairing", "Do you really want to unpair the pump?", ok = Runnable { + unpair() + }) + } + false + } + + // Setup coroutine to enable/disable the pair and unpair + // preferences depending on the pairing state. + preferenceFragment.run { + // TODO: Verify that the lifecycle and coroutinescope are correct here. + // We want to avoid duplicate coroutine launches and premature coroutine terminations. + // The viewLifecycle does not work here since this is called before onCreateView() is, + // and it is questionable whether the viewLifecycle is even the one to use - verify + // that lifecycle instead of viewLifecycle is the correct choice. + lifecycle.coroutineScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + val pairPref: Preference? = findPreference(rh.gs(R.string.key_combov2_pair_with_pump)) + val unpairPref: Preference? = findPreference(rh.gs(R.string.key_combov2_unpair_pump)) + + val isInitiallyPaired = pairedStateUIFlow.value + pairPref?.isEnabled = !isInitiallyPaired + unpairPref?.isEnabled = isInitiallyPaired + + pairedStateUIFlow + .onEach { isPaired -> + pairPref?.isEnabled = !isPaired + unpairPref?.isEnabled = isPaired + } + .launchIn(this) + } + } + } + } + + override fun isInitialized(): Boolean = + isPaired() && (driverStateFlow.value != DriverState.NotInitialized) + + override fun isSuspended(): Boolean = + when (driverStateUIFlow.value) { + DriverState.Suspended, + DriverState.Error -> true + else -> false + } + + override fun isBusy(): Boolean = + when (driverStateFlow.value) { + DriverState.Connecting, + DriverState.CheckingPump, + is DriverState.ExecutingCommand -> true + else -> false + } + + override fun isConnected(): Boolean = + when (driverStateFlow.value) { + // NOTE: Even though the Combo is technically already connected by the + // time the DriverState.CheckingPump state is reached, do not return + // true then. That's because the pump still tries to issue commands + // during that state even though isBusy() returns true. Worse, it + // might try to call connect()! + // TODO: Check why this happens. + DriverState.Ready, + DriverState.Suspended, + is DriverState.ExecutingCommand -> true + else -> false + } + + override fun isConnecting(): Boolean = + when (driverStateFlow.value) { + DriverState.Connecting, + DriverState.CheckingPump -> true + else -> false + } + + // There is no corresponding indicator for this + // in Combo connections, so just return false + override fun isHandshakeInProgress() = false + + override fun connect(reason: String) { + aapsLogger.debug(LTag.PUMP, "Connecting to Combo; reason: $reason") + + when (driverStateFlow.value) { + DriverState.Connecting, + DriverState.CheckingPump, + DriverState.Ready, + DriverState.Suspended, + is DriverState.ExecutingCommand, + DriverState.Error -> { + aapsLogger.debug( + LTag.PUMP, + "Cannot connect while driver is in the ${driverStateFlow.value} state" + ) + return + } + else -> Unit + } + + if (!isPaired()) { + aapsLogger.debug(LTag.PUMP, "Cannot connect since no Combo has been paired") + return + } + + // It makes no sense to reach this location with pump + // being non-null due to the checks above. + assert(pump == null) + + lastComboAlert = null + pumpStatus = null + + val bluetoothAddress = when (val address = getBluetoothAddress()) { + null -> { + aapsLogger.error(LTag.PUMP, "No Bluetooth address stored - pump state store may be corrupted") + unpairDueToPumpDataError() + return + } + else -> address + } + + try { + runBlocking { + pump = pumpManager?.acquirePump(bluetoothAddress, activeBasalProfile) { event -> handlePumpEvent(event) } + } + + if (pump == null) { + aapsLogger.error(LTag.PUMP, "Could not get pump instance - pump state store may be corrupted") + unpairDueToPumpDataError() + return + } + + _bluetoothAddressUIFlow.value = bluetoothAddress.toString() + _serialNumberUIFlow.value = pumpManager!!.getPumpID(bluetoothAddress) + + // Erase any display frame that may be left over from a previous connection. + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + _displayFrameUIFlow.resetReplayCache() + + stateAndStatusFlowsDeferred = pumpCoroutineScope.async { + coroutineScope { + pump!!.stateFlow + .onEach { pumpState -> + val driverState = when (pumpState) { + // The Disconnected pump state is ignored, since the Disconnected + // *driver* state is manually set anyway when disconnecting in + // in connect() and disconnectInternal(). Passing it to setDriverState() + // here would trigger an EventPumpStatusChanged event to be sent over + // the rxBus too early, potentially causing a situation where the connect() + // call isn't fully done yet, but the queue gets that event and thinks that + // it can try to reconnect now. + ComboCtlPump.State.Disconnected -> return@onEach + ComboCtlPump.State.Connecting -> DriverState.Connecting + ComboCtlPump.State.CheckingPump -> DriverState.CheckingPump + ComboCtlPump.State.ReadyForCommands -> DriverState.Ready + is ComboCtlPump.State.ExecutingCommand -> DriverState.ExecutingCommand(pumpState.description) + ComboCtlPump.State.Suspended -> DriverState.Suspended + is ComboCtlPump.State.Error -> DriverState.Error + } + setDriverState(driverState) + } + .launchIn(this) + pump!!.statusFlow + .onEach { newPumpStatus -> + if (newPumpStatus == null) + return@onEach + + _batteryStateUIFlow.value = newPumpStatus.batteryState + _reservoirLevelUIFlow.value = ReservoirLevel( + newPumpStatus.reservoirState, + newPumpStatus.availableUnitsInReservoir + ) + + pumpStatus = newPumpStatus + updateLevels() + + // Send the EventRefreshOverview to keep the overview fragment's content + // up to date. Other actions like a CommandQueue.readStatus() call trigger + // such a refresh, but if the pump status is updated by something else, + // a refresh may not happen automatically. This event send call eliminates + // that possibility. + rxBus.send(EventRefreshOverview("ComboV2 pump status updated")) + } + .launchIn(this) + pump!!.lastBolusFlow + .onEach { lastBolus -> + if (lastBolus == null) + return@onEach + + _lastBolusUIFlow.value = lastBolus + } + .launchIn(this) + pump!!.currentTbrFlow + .onEach { currentTbr -> + _currentTbrUIFlow.value = currentTbr + } + .launchIn(this) + } + } + + setupUiFlows() + + //// + // The actual connect procedure begins here. + //// + + disconnectRequestPending = false + setDriverState(DriverState.Connecting) + + connectionSetupJob = pumpCoroutineScope.launch { + var forciblyDisconnectDueToError = false + + try { + // Set maxNumAttempts to null to turn off the connection attempt limit inside the connect() call. + // The AAPS queue thread will anyway cause the connectionSetupJob to be canceled when its + // connection timeout expires, so the Pump class' own connection attempt limiter is redundant. + pump?.connect(maxNumAttempts = null) + + // No need to set the driver state here, since the pump's stateFlow will announce that. + + pump?.let { + // We can't read the active profile number in the suspended state, since + // the Combo's screen does not show any profile number then. + if (!isSuspended()) { + // Get the active basal profile number. If it is not profile #1, alert + // the user. We also keep a copy of that number to be able to disable + // loop invocation if this isn't profile #1 (see the implementation of + // isLoopInvocationAllowed() below). + val activeBasalProfileNumber = it.statusFlow.value?.activeBasalProfileNumber + aapsLogger.debug(LTag.PUMP, "Active basal profile number: $activeBasalProfileNumber") + if ((activeBasalProfileNumber != null) && (activeBasalProfileNumber != 1)) { + val notification = Notification( + Notification.COMBO_PUMP_ALARM, + text = rh.gs(R.string.combov2_incorrect_active_basal_profile, activeBasalProfileNumber), + level = Notification.URGENT + ) + rxBus.send(EventNewNotification(notification)) + } + lastActiveBasalProfileNumber = activeBasalProfileNumber + } + + // Read the pump's basal profile to know later, when the loop attempts + // to set new profile, whether this procedure is redundant or now. + if (activeBasalProfile == null) { + aapsLogger.debug( + LTag.PUMP, + "No basal profile specified by pump queue (yet); using the basal profile that got read from the pump" + ) + activeBasalProfile = it.currentBasalProfile + } + updateBaseBasalRateUI() + } + } catch (e: CancellationException) { + // In case of a cancellation, the Pump.connect() call + // rolls back any partially started connection and + // switches back to the disconnected state automatically. + // We just clean up our states here to reflect that the + // pump is already disconnected by this point. + disconnectRequestPending = false + setDriverState(DriverState.Disconnected) + // Re-throw to mark this coroutine as cancelled. + throw e + } catch (e: AlertScreenException) { + notifyAboutComboAlert(e.alertScreenContent) + forciblyDisconnectDueToError = true + } catch (e: Exception) { + val notification = Notification( + Notification.COMBO_PUMP_ALARM, + text = rh.gs(R.string.combov2_connection_error, e.message), + level = Notification.URGENT + ) + rxBus.send(EventNewNotification(notification)) + + aapsLogger.error(LTag.PUMP, "Exception while connecting: ${e.stackTraceToString()}") + + forciblyDisconnectDueToError = true + } + + if (forciblyDisconnectDueToError) { + // In case of a connection failure, just disconnect. The command + // queue will retry after a while. Repeated failed attempts will + // eventually trigger a "pump unreachable" error message. + // + // Set this to null _before_ disconnecting, since + // disconnectInternal() tries to call cancelAndJoin() + // on connectionSetupJob, leading to a deadlock. + // connectionSetupJob is set to null further below + // as well before the executePendingDisconnect() + // call, for the same reason. This coroutine is + // close to ending anyway, and there won't be any + // coroutine suspension happening anymore, so there's + // no point in a such cancelAndJoin() call by now. + connectionSetupJob = null + disconnectInternal(forceDisconnect = true) + + ToastUtils.showToastInUiThread(context, rh.gs(R.string.combov2_could_not_connect)) + } else { + connectionSetupJob = null + // In case the pump queue issued a disconnect while the checks + // were running inside the connect() call above, do that + // postponed disconnect now. (The checks can take a long time + // if for example the pump's datetime deviates significantly + // from the system's current datetime.) + executePendingDisconnect() + } + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMP, "Connection failure: $e") + ToastUtils.showToastInUiThread(context, rh.gs(R.string.combov2_could_not_connect)) + disconnectInternal(forceDisconnect = true) + } + } + + override fun disconnect(reason: String) { + aapsLogger.debug(LTag.PUMP, "Disconnecting from Combo; reason: $reason") + disconnectInternal(forceDisconnect = false) + } + + // This is called when (a) the AAPS watchdog is about to toggle + // Bluetooth (if permission is given by the user) and (b) when + // the command queue is being emptied. In both cases, the + // connection attempt must be stopped immediately, which is why + // forceDisconnect is set to true. + override fun stopConnecting() { + aapsLogger.debug(LTag.PUMP, "Stopping connect attempt by (forcibly) disconnecting") + disconnectInternal(forceDisconnect = true) + } + + override fun getPumpStatus(reason: String) { + aapsLogger.debug(LTag.PUMP, "Getting pump status; reason: $reason") + + lastComboAlert = null + + runBlocking { + try { + executeCommand { + pump?.updateStatus() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + } + } + + // State and status are automatically updated via the associated flows. + } + + override fun setNewBasalProfile(profile: Profile): PumpEnactResult { + if (!isInitialized()) { + aapsLogger.error(LTag.PUMP, "Cannot set profile since driver is not initialized") + + val notification = Notification( + Notification.PROFILE_NOT_SET_NOT_INITIALIZED, + rh.gs(R.string.pumpNotInitializedProfileNotSet), + Notification.URGENT + ) + rxBus.send(EventNewNotification(notification)) + + return PumpEnactResult(injector).apply { + success = false + enacted = false + comment = rh.gs(R.string.pumpNotInitializedProfileNotSet) + } + } + + rxBus.send(EventDismissNotification(Notification.PROFILE_NOT_SET_NOT_INITIALIZED)) + rxBus.send(EventDismissNotification(Notification.FAILED_UPDATE_PROFILE)) + + val pumpEnactResult = PumpEnactResult(injector) + + val requestedBasalProfile = profile.toComboCtlBasalProfile() + aapsLogger.debug(LTag.PUMP, "Basal profile to set: $requestedBasalProfile") + + runBlocking { + try { + executeCommand { + if (pump!!.setBasalProfile(requestedBasalProfile)) { + aapsLogger.debug(LTag.PUMP, "Basal profiles are different; new profile set") + activeBasalProfile = requestedBasalProfile + updateBaseBasalRateUI() + + val notification = Notification( + Notification.PROFILE_SET_OK, + rh.gs(R.string.profile_set_ok), + Notification.INFO, + 60 + ) + rxBus.send(EventNewNotification(notification)) + + pumpEnactResult.apply { + success = true + enacted = true + } + } else { + aapsLogger.debug(LTag.PUMP, "Basal profiles are equal; did not have to set anything") + pumpEnactResult.apply { + success = true + enacted = false + } + } + } + } catch (e: CancellationException) { + // Cancellation is not an error, but it also means + // that the profile update was not enacted. + pumpEnactResult.apply { + success = true + enacted = false + } + throw e + } catch (e: Exception) { + aapsLogger.error("Exception thrown during basal profile update: $e") + + val notification = Notification( + Notification.FAILED_UPDATE_PROFILE, + rh.gs(R.string.failedupdatebasalprofile), + Notification.URGENT + ) + rxBus.send(EventNewNotification(notification)) + + pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.failedupdatebasalprofile) + } + } + } + return pumpEnactResult + } + + override fun isThisProfileSet(profile: Profile): Boolean { + if (!isInitialized()) + return true + + return (activeBasalProfile == profile.toComboCtlBasalProfile()) + } + + override fun lastDataTime(): Long = lastConnectionTimestamp + + override val baseBasalRate: Double + get() { + val currentHour = DateTime().hourOfDay().get() + return activeBasalProfile?.get(currentHour)?.cctlBasalToIU() ?: 0.0 + } + + // Store the levels as plain properties. That way, the last reported + // levels are shown on the UI even when the driver connects to the + // pump again and resets the current pump state. + + private var _reservoirLevel: Double? = null + override val reservoirLevel: Double + get() = _reservoirLevel ?: 0.0 + + private var _batteryLevel: Int? = null + override val batteryLevel: Int + get() = _batteryLevel ?: 0 + + private fun updateLevels() { + pumpStatus?.availableUnitsInReservoir?.let { newLevel -> + _reservoirLevel?.let { currentLevel -> + aapsLogger.debug(LTag.PUMP, "Current/new reservoir levels: $currentLevel / $newLevel") + if (sp.getBoolean(R.string.key_combov2_automatic_reservoir_entry, true) && (newLevel > currentLevel)) { + aapsLogger.debug(LTag.PUMP, "Auto-inserting reservoir change therapy event") + pumpSync.insertTherapyEventIfNewWithTimestamp( + timestamp = System.currentTimeMillis(), + type = DetailedBolusInfo.EventType.INSULIN_CHANGE, + note = rh.gs(R.string.combov2_note_reservoir_change), + pumpId = null, + pumpType = PumpType.ACCU_CHEK_COMBO, + pumpSerial = serialNumber() + ) + } + } + + _reservoirLevel = newLevel.toDouble() + } + + pumpStatus?.batteryState?.let { newState -> + val newLevel = when (newState) { + BatteryState.NO_BATTERY -> 5 + BatteryState.LOW_BATTERY -> 25 + BatteryState.FULL_BATTERY -> 100 + } + + _batteryLevel?.let { currentLevel -> + aapsLogger.debug(LTag.PUMP, "Current/new battery levels: $currentLevel / $newLevel") + if (sp.getBoolean(R.string.key_combov2_automatic_battery_entry, true) && (newLevel > currentLevel)) { + aapsLogger.debug(LTag.PUMP, "Auto-inserting battery change therapy event") + pumpSync.insertTherapyEventIfNewWithTimestamp( + timestamp = System.currentTimeMillis(), + type = DetailedBolusInfo.EventType.PUMP_BATTERY_CHANGE, + note = rh.gs(R.string.combov2_note_battery_change), + pumpId = null, + pumpType = PumpType.ACCU_CHEK_COMBO, + pumpSerial = serialNumber() + ) + } + } + + _batteryLevel = newLevel + } + } + + override fun deliverTreatment(detailedBolusInfo: DetailedBolusInfo): PumpEnactResult { + val oldInsulinAmount = detailedBolusInfo.insulin + detailedBolusInfo.insulin = constraintChecker + .applyBolusConstraints(Constraint(detailedBolusInfo.insulin)) + .value() + aapsLogger.debug( + LTag.PUMP, + "Applied bolus constraints: old insulin amount: $oldInsulinAmount new: ${detailedBolusInfo.insulin}" + ) + + // Carbs are not allowed because the Combo does not record carbs. + // This is defined in the ACCU_CHEK_COMBO PumpType enum's + // pumpCapability field, so AndroidAPS is informed about this + // lack of carb storage capability. We therefore do not expect + // nonzero carbs here. + // (Also, a zero insulin value makes no sense when bolusing.) + require((detailedBolusInfo.insulin > 0) && (detailedBolusInfo.carbs <= 0.0)) { detailedBolusInfo.toString() } + + val requestedBolusAmount = detailedBolusInfo.insulin.iuToCctlBolus() + val bolusReason = when (detailedBolusInfo.bolusType) { + DetailedBolusInfo.BolusType.NORMAL -> ComboCtlPump.StandardBolusReason.NORMAL + DetailedBolusInfo.BolusType.SMB -> ComboCtlPump.StandardBolusReason.SUPERBOLUS + DetailedBolusInfo.BolusType.PRIMING -> ComboCtlPump.StandardBolusReason.PRIMING_INFUSION_SET + } + + val pumpEnactResult = PumpEnactResult(injector) + pumpEnactResult.success = false + + // Set up initial bolus progress along with details that are invariant. + // FIXME: EventOverviewBolusProgress is a singleton purely for + // historical reasons and could be updated to be a regular + // class. So far, this hasn't been done, so we must use it + // like a singleton, at least for now. + EventOverviewBolusProgress.t = Treatment( + insulin = 0.0, + carbs = 0, + isSMB = detailedBolusInfo.bolusType === DetailedBolusInfo.BolusType.SMB, + id = detailedBolusInfo.id + ) + + val bolusProgressJob = pumpCoroutineScope.launch { + pump!!.bolusDeliveryProgressFlow + .collect { progressReport -> + when (progressReport.stage) { + is RTCommandProgressStage.DeliveringBolus -> { + val bolusingEvent = EventOverviewBolusProgress + bolusingEvent.percent = (progressReport.overallProgress * 100.0).toInt() + bolusingEvent.status = rh.gs(R.string.bolusdelivering, detailedBolusInfo.insulin) + rxBus.send(bolusingEvent) + } + BasicProgressStage.Finished -> { + val bolusingEvent = EventOverviewBolusProgress + bolusingEvent.percent = (progressReport.overallProgress * 100.0).toInt() + bolusingEvent.status = "Bolus finished, performing post-bolus checks" + rxBus.send(bolusingEvent) + } + else -> Unit + } + } + } + + // Run the delivery in a sub-coroutine to be able + // to cancel it via stopBolusDelivering(). + val newBolusJob = pumpCoroutineScope.async { + // Store a local reference to the Pump instance. "pump" + // is set to null in case of an error, because then, + // disconnectInternal() is called (which sets pump to null). + // However, we still need to access the last deliverd bolus + // from the pump's lastBolusFlow, even if an error happened. + // Solve this by storing this reference and accessing the + // lastBolusFlow through it. + val acquiredPump = pump!! + + try { + executeCommand { + acquiredPump.deliverBolus(requestedBolusAmount, bolusReason) + } + + reportFinishedBolus(rh.gs(R.string.bolusdelivered, detailedBolusInfo.insulin), pumpEnactResult, succeeded = true) + + // TODO: Check that an alert sound and error dialog + // are produced if an exception was thrown that + // counts as an error + } catch (e: CancellationException) { + // Cancellation is not an error, but it also means + // that the profile update was not enacted. + + reportFinishedBolus(R.string.combov2_bolus_cancelled, pumpEnactResult, succeeded = true) + + // Rethrowing to finish coroutine cancellation. + throw e + } catch (e: ComboCtlPump.BolusCancelledByUserException) { + aapsLogger.info(LTag.PUMP, "Bolus cancelled via Combo CMD_CANCEL_BOLUS command") + + // This exception is thrown when the bolus is cancelled + // through a cancel bolus command that was sent to the Combo, + // and not due to a coroutine cancellation. Like the + // CancellationException block above, this is not an + // error, hence the "success = true". + + reportFinishedBolus(R.string.combov2_bolus_cancelled, pumpEnactResult, succeeded = true) + } catch (e: ComboCtlPump.BolusNotDeliveredException) { + aapsLogger.error(LTag.PUMP, "Bolus not delivered") + reportFinishedBolus(R.string.combov2_bolus_not_delivered, pumpEnactResult, succeeded = false) + } catch (e: ComboCtlPump.UnaccountedBolusDetectedException) { + aapsLogger.error(LTag.PUMP, "Unaccounted bolus detected") + reportFinishedBolus(R.string.combov2_unaccounted_bolus_detected_cancelling_bolus, pumpEnactResult, succeeded = false) + } catch (e: ComboCtlPump.InsufficientInsulinAvailableException) { + aapsLogger.error(LTag.PUMP, "Insufficient insulin in reservoir") + reportFinishedBolus(R.string.combov2_insufficient_insulin_in_reservoir, pumpEnactResult, succeeded = false) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMP, "Exception thrown during bolus delivery: $e") + reportFinishedBolus(R.string.combov2_bolus_delivery_failed, pumpEnactResult, succeeded = false) + } finally { + // The delivery was enacted if even a partial amount was infused. + pumpEnactResult.enacted = acquiredPump.lastBolusFlow.value?.let { it.bolusAmount > 0 } ?: false + bolusJob = null + bolusProgressJob.cancelAndJoin() + } + } + + bolusJob = newBolusJob + + // Do a blocking wait until the bolus coroutine completes or is cancelled. + // AndroidAPS expects deliverTreatment() calls to block and to be cancellable + // (via stopBolusDelivering()), so we run a separate bolus coroutine and + // wait here until it is done. + runBlocking { + try { + aapsLogger.debug(LTag.PUMP, "Waiting for bolus coroutine to finish") + newBolusJob.join() + aapsLogger.debug(LTag.PUMP, "Bolus coroutine finished") + } catch (_: CancellationException) { + aapsLogger.debug(LTag.PUMP, "Bolus coroutine was cancelled") + } + } + + return pumpEnactResult + } + + override fun stopBolusDelivering() { + aapsLogger.debug(LTag.PUMP, "Stopping bolus delivery") + runBlocking { + bolusJob?.cancelAndJoin() + bolusJob = null + } + aapsLogger.debug(LTag.PUMP, "Bolus delivery stopped") + } + + override fun setTempBasalAbsolute(absoluteRate: Double, durationInMinutes: Int, profile: Profile, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult { + val pumpEnactResult = PumpEnactResult(injector) + pumpEnactResult.isPercent = false + + // Corner case: Current base basal rate is 0 IU. We cannot do + // anything then, otherwise we get into a division by zero below + // when converting absoluteRate to a percentage. + if (baseBasalRate == 0.0) { + pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.combov2_cannot_set_absolute_tbr_if_basal_zero) + } + return pumpEnactResult + } + + // The Combo cannot handle absolute rates directly. + // We have to convert it to a percentage instead, + // and the percentage must be an integer multiple + // of 10, otherwise the Combo won't accept it. + + val percentage = absoluteRate / baseBasalRate * 100 + val roundedPercentage = ((absoluteRate / baseBasalRate * 10).roundToInt() * 10) + val limitedPercentage = min(roundedPercentage, _pumpDescription.maxTempPercent) + + aapsLogger.debug(LTag.PUMP, "Calculated percentage of $percentage% out of absolute rate $absoluteRate; rounded to: $roundedPercentage%; limited to: $limitedPercentage%") + + val cctlTbrType = when (tbrType) { + PumpSync.TemporaryBasalType.NORMAL -> ComboCtlTbr.Type.NORMAL + PumpSync.TemporaryBasalType.EMULATED_PUMP_SUSPEND -> ComboCtlTbr.Type.EMULATED_COMBO_STOP + PumpSync.TemporaryBasalType.PUMP_SUSPEND -> ComboCtlTbr.Type.COMBO_STOPPED // TODO: Can this happen? It is currently not allowed by ComboCtlPump.setTbr() + PumpSync.TemporaryBasalType.SUPERBOLUS -> ComboCtlTbr.Type.SUPERBOLUS + } + + setTbrInternal(limitedPercentage, durationInMinutes, cctlTbrType, force100Percent = false, pumpEnactResult) + + return pumpEnactResult + } + + override fun setTempBasalPercent(percent: Int, durationInMinutes: Int, profile: Profile, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult { + val pumpEnactResult = PumpEnactResult(injector) + pumpEnactResult.isPercent = true + + val roundedPercentage = ((percent + 5) / 10) * 10 + val limitedPercentage = min(roundedPercentage, _pumpDescription.maxTempPercent) + aapsLogger.debug(LTag.PUMP, "Got percentage of $percent%; rounded to: $roundedPercentage%; limited to: $limitedPercentage%") + + val cctlTbrType = when (tbrType) { + PumpSync.TemporaryBasalType.NORMAL -> ComboCtlTbr.Type.NORMAL + PumpSync.TemporaryBasalType.EMULATED_PUMP_SUSPEND -> ComboCtlTbr.Type.EMULATED_COMBO_STOP + PumpSync.TemporaryBasalType.PUMP_SUSPEND -> ComboCtlTbr.Type.COMBO_STOPPED // TODO: Can this happen? It is currently not allowed by ComboCtlPump.setTbr() + PumpSync.TemporaryBasalType.SUPERBOLUS -> ComboCtlTbr.Type.SUPERBOLUS + } + + setTbrInternal(limitedPercentage, durationInMinutes, cctlTbrType, force100Percent = false, pumpEnactResult) + + return pumpEnactResult + } + + override fun cancelTempBasal(enforceNew: Boolean): PumpEnactResult { + // TODO: Check if some of the additional checks in ComboPlugin.cancelTempBasal can be carried over here. + // Note that ComboCtlPump.setTbr itself checks the TBR that is actually active after setting the TBR + // is done, and throws exceptions when there's a mismatch. It considers mismatches as an error, unlike + // the ComboPlugin.cancelTempBasal code, which just sets enact to false when there's a mismatch. + + val pumpEnactResult = PumpEnactResult(injector) + pumpEnactResult.isPercent = true + pumpEnactResult.isTempCancel = enforceNew + setTbrInternal(100, 0, tbrType = ComboCtlTbr.Type.NORMAL, force100Percent = enforceNew, pumpEnactResult) + return pumpEnactResult + } + + private fun setTbrInternal(percentage: Int, durationInMinutes: Int, tbrType: ComboCtlTbr.Type, force100Percent: Boolean, pumpEnactResult: PumpEnactResult) { + runBlocking { + try { + executeCommand { + pump!!.setTbr(percentage, durationInMinutes, tbrType, force100Percent) + } + + pumpEnactResult.apply { + success = true + enacted = true + comment = rh.gs(R.string.combov2_setting_tbr_succeeded) + } + } catch (e: QuantityNotChangingException) { + aapsLogger.error(LTag.PUMP, "TBR percentage adjustment hit a limit: $e") + pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.combov2_hit_unexpected_tbr_limit, e.targetQuantity, e.hitLimitAt) + } + } catch (e: ComboCtlPump.UnexpectedTbrStateException) { + aapsLogger.error(LTag.PUMP, "Setting TBR failed with exception: $e") + pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.combov2_setting_tbr_failed) + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMP, "Setting TBR failed with exception: $e") + pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.combov2_setting_tbr_failed) + } + } + } + } + + // It is currently not known how to program an extended bolus into the Combo. + // Until that is reverse engineered, inform callers that we can't handle this. + + override fun setExtendedBolus(insulin: Double, durationInMinutes: Int): PumpEnactResult = + createFailurePumpEnactResult(R.string.combov2_extended_bolus_not_supported) + + override fun cancelExtendedBolus(): PumpEnactResult = + createFailurePumpEnactResult(R.string.combov2_extended_bolus_not_supported) + + override fun getJSONStatus(profile: Profile, profileName: String, version: String): JSONObject { + if (!isInitialized()) + return JSONObject() + + val now = dateUtil.now() + if ((lastConnectionTimestamp != 0L) && ((now - lastConnectionTimestamp) > 60 * 60 * 1000)) { + return JSONObject() + } + val pumpJson = JSONObject() + + try { + pumpJson.apply { + put("clock", dateUtil.toISOString(now)) + // NOTE: This is called "status" because this is what the + // Nightscout pump plugin API schema expects. It is not to + // be confused with the "status" in the ComboCtl Pump class. + // Also not to be confused with the "status" field inside + // this "status" JSON object. + // See the Nightscout /devicestatus/ API docs for more. + put("status", JSONObject().apply { + val driverState = driverStateFlow.value + val suspended = isSuspended() + val bolusing = (driverState is DriverState.ExecutingCommand) && + (driverState.description is ComboCtlPump.DeliveringBolusCommandDesc) + // The value of the "status" string isn't well defined. + // Commonly used ones seem to be "normal", "suspended", + // and "bolusing". The latter two are already enforced + // by the corresponding boolean flags, but we set them + // in this string anyway. It may be a legacy feature + // from older Nightscout iterations. Furthermore, we do + // set this to "error" in case of pump errors to alert + // users of Nightscout to possible problems with the pump. + val statusLabel = if (bolusing) + "bolusing" + else if (suspended) + "suspended" + else if (driverState == DriverState.Error) + "error" + else + "normal" + put("status", statusLabel) + put("suspended", suspended) + put("bolusing", bolusing) + put("timestamp", dateUtil.toISOString(lastConnectionTimestamp)) + }) + pumpStatus?.let { + // Battery level is set inside this let-block as well. Even though + // batteryLevel is not a direct pumpStatus member, it is a property + // that *does* access pumpStatus (with null check). + put("battery", JSONObject().apply { + put("percent", batteryLevel) + }) + put("reservoir", it.availableUnitsInReservoir) + } ?: aapsLogger.info( + LTag.PUMP, + "Cannot include reservoir level in JSON status " + + "since no such level is currently known" + ) + put("extended", JSONObject().apply { + put("Version", version) + lastBolusUIFlow.value?.let { + put("LastBolus", dateUtil.dateAndTimeString(it.timestamp.toEpochMilliseconds())) + put("LastBolusAmount", it.bolusAmount.cctlBolusToIU()) + } + val tb = pumpSync.expectedPumpState().temporaryBasal + tb?.let { + put("TempBasalAbsoluteRate", tb.convertedToAbsolute(now, profile)) + put("TempBasalStart", dateUtil.dateAndTimeString(tb.timestamp)) + put("TempBasalRemaining", tb.plannedRemainingMinutes) + } + if (activeBasalProfile != null) + put("BaseBasalRate", baseBasalRate) + else + aapsLogger.info( + LTag.PUMP, + "Cannot include base basal rate in JSON status " + + "since no basal profile is currently active" + ) + try { + // TODO: What about the profileName argument? + // Is it obsolete? + put("ActiveProfile", profileFunction.getProfileName()) + } catch (e: Exception) { + aapsLogger.error("Unhandled exception", e) + } + when (val alert = lastComboAlert) { + is AlertScreenContent.Warning -> + put("WarningCode", alert.code) + is AlertScreenContent.Error -> + put("ErrorCode", alert.code) + else -> Unit + } + }) + } + } catch (e: JSONException) { + aapsLogger.error(LTag.PUMP, "Unhandled JSON exception", e) + } + aapsLogger.info(LTag.PUMP, "Produced pump JSON status: $pumpJson") + + return pumpJson + } + + override fun manufacturer() = ManufacturerType.Roche + + override fun model() = PumpType.ACCU_CHEK_COMBO + + override fun serialNumber(): String { + val bluetoothAddress = getBluetoothAddress() + return if ((bluetoothAddress != null) && (pumpManager != null)) + pumpManager!!.getPumpID(bluetoothAddress) + else + rh.gs(R.string.combov2_not_paired) + } + + override val pumpDescription: PumpDescription + get() = _pumpDescription + + override fun shortStatus(veryShort: Boolean): String { + val lines = mutableListOf() + + if (lastConnectionTimestamp != 0L) { + val agoMsec: Long = System.currentTimeMillis() - lastConnectionTimestamp + val agoMin = (agoMsec / 60.0 / 1000.0).toInt() + lines += rh.gs(R.string.combov2_short_status_last_connection, agoMin) + } + + val alertCodeString = when (val alert = lastComboAlert) { + is AlertScreenContent.Warning -> "W${alert.code}" + is AlertScreenContent.Error -> "E${alert.code}" + else -> null + } + if (alertCodeString != null) + lines += rh.gs(R.string.combov2_short_status_alert, alertCodeString) + + lastBolusUIFlow.value?.let { + val localBolusTimestamp = it.timestamp.toLocalDateTime(TimeZone.currentSystemDefault()) + lines += rh.gs( + R.string.combov2_short_status_last_bolus, DecimalFormatter.to2Decimal(it.bolusAmount.cctlBolusToIU()), + String.format("%02d:%02d", localBolusTimestamp.hour, localBolusTimestamp.minute) + ) + } + + val temporaryBasal = pumpSync.expectedPumpState().temporaryBasal + temporaryBasal?.let { + lines += rh.gs( + R.string.combov2_short_status_temp_basal, + it.toStringFull(dateUtil) + ) + } + + pumpStatus?.let { + lines += rh.gs( + R.string.combov2_short_status_reservoir, + it.availableUnitsInReservoir + ) + val batteryStateDesc = when (it.batteryState) { + BatteryState.NO_BATTERY -> rh.gs(R.string.combov2_short_status_battery_state_empty) + BatteryState.LOW_BATTERY -> rh.gs(R.string.combov2_short_status_battery_state_low) + BatteryState.FULL_BATTERY -> rh.gs(R.string.combov2_short_status_battery_state_full) + } + lines += rh.gs( + R.string.combov2_short_status_battery_state, + batteryStateDesc + ) + } + + val shortStatusString = lines.joinToString("\n") + + aapsLogger.debug(LTag.PUMP, "Produced short status: [$shortStatusString]") + + return shortStatusString + } + + override val isFakingTempsByExtendedBoluses = false + + override fun loadTDDs(): PumpEnactResult { + val pumpEnactResult = PumpEnactResult(injector) + + runBlocking { + try { + // Map key = timestamp; value = TDD + val tddMap = mutableMapOf() + + executeCommand { + val tddHistory = pump!!.fetchTDDHistory() + + tddHistory + .filter { it.totalDailyAmount >= 1 } + .forEach { tddHistoryEntry -> + val timestamp = tddHistoryEntry.date.toEpochMilliseconds() + tddMap[timestamp] = (tddMap[timestamp] ?: 0) + tddHistoryEntry.totalDailyAmount + } + } + + for (tddEntry in tddMap) { + val timestamp = tddEntry.key + val totalDailyAmount = tddEntry.value + + pumpSync.createOrUpdateTotalDailyDose( + timestamp, + bolusAmount = 0.0, + basalAmount = 0.0, + totalAmount = totalDailyAmount.cctlBasalToIU(), + pumpId = null, + pumpType = PumpType.ACCU_CHEK_COMBO, + pumpSerial = serialNumber() + ) + } + + pumpEnactResult.apply { + success = true + enacted = true + } + } catch (e: CancellationException) { + pumpEnactResult.apply { + success = true + enacted = false + comment = rh.gs(R.string.combov2_load_tdds_cancelled) + } + throw e + } catch (e: Exception) { + aapsLogger.error("Exception thrown during TDD retrieval: $e") + + pumpEnactResult.apply { + success = false + enacted = false + comment = rh.gs(R.string.combov2_retrieving_tdds_failed) + } + } + } + + return pumpEnactResult + } + + override fun canHandleDST() = true + + override fun timezoneOrDSTChanged(timeChangeType: TimeChangeType) { + // Currently just logging this; the ComboCtl.Pump code will set the new datetime + // (as localtime) as part of the on-connect checks automatically. + // TODO: It may be useful to do this here, since setting the datetime takes + // a while with the Combo. It has to be done via the RT mode, which is slow. + aapsLogger.info(LTag.PUMP, "Time, Date and/or TimeZone changed. Time change type = $timeChangeType") + } + + /*** Loop constraints ***/ + // These restrict the function of the loop in case of an event + // that makes running a loop too risky, for example because something + // went wrong while bolusing, or because the incorrect basal profile + // was found to be active. + + override fun isLoopInvocationAllowed(value: Constraint): Constraint { + if (!isSuspended() && (lastActiveBasalProfileNumber != null)) { + val isAllowed = (lastActiveBasalProfileNumber == 1) + aapsLogger.info( + LTag.PUMP, + "Currently active basal profile: $lastActiveBasalProfileNumber -> loop invocation allowed: $isAllowed" + ) + + if (!isAllowed) { + value.set( + aapsLogger, + false, + rh.gs(R.string.combov2_incorrect_active_basal_profile, lastActiveBasalProfileNumber), + this + ) + } + } else { + aapsLogger.info( + LTag.PUMP, + "Cannot currently determine which basal profile is active in the pump" + ) + // We don't disallow the invocation in this case since the only reasons for lastActiveBasalProfileNumber + // being null are (1) we are in the initial, uninitialized state (in which case looping won't + // work anyway) and (2) the pump is currently suspended (which already will not allow for looping). + } + + return value + } + + /*** Pairing API ***/ + + fun getPairingProgressFlow() = + pumpManager?.pairingProgressFlow ?: throw IllegalStateException("Attempting access uninitialized pump manager") + + fun resetPairingProgress() = pumpManager?.resetPairingProgress() + + private val _previousPairingAttemptFailedFlow = MutableStateFlow(false) + val previousPairingAttemptFailedFlow = _previousPairingAttemptFailedFlow.asStateFlow() + + private var pairingJob: Job? = null + private var pairingPINChannel: Channel? = null + + fun startPairing() { + val discoveryDuration = sp.getInt(R.string.key_combov2_discovery_duration, 300) + + val newPINChannel = Channel(capacity = Channel.RENDEZVOUS) + pairingPINChannel = newPINChannel + + _previousPairingAttemptFailedFlow.value = false + + // Update the log level here in case the user changed it. + updateComboCtlLogLevel() + + pairingJob = pumpCoroutineScope.async { + try { + val pairingResult = pumpManager?.pairWithNewPump(discoveryDuration) { newPumpAddress, previousAttemptFailed -> + aapsLogger.info( + LTag.PUMP, + "New pairing PIN request from Combo pump with Bluetooth " + + "address $newPumpAddress (previous attempt failed: $previousAttemptFailed)" + ) + _previousPairingAttemptFailedFlow.value = previousAttemptFailed + newPINChannel.receive() + } ?: throw IllegalStateException("Attempting to access uninitialized pump manager") + + if (pairingResult !is ComboCtlPumpManager.PairingResult.Success) + return@async + + _pairedStateUIFlow.value = true + + // Notify AndroidAPS that this is a new pump and that + // the history that is associated with any previously + // paired pump is to be discarded. + pumpSync.connectNewPump() + + // Schedule a status update, since pairing can take + // a while. By the time we reach this point, the queue + // connection attempt may have reached the timeout, + // and reading the status is part of what AndroidAPS + // was trying to do, so do that now. + // If we reach this point before the timeout, then the + // queue will contain a pump_driver_changed readstatus + // command already. The queue will see that and ignore + // this readStatus() call automatically. + commandQueue.readStatus(rh.gs(R.string.pump_paired), null) + } finally { + pairingJob = null + pairingPINChannel?.close() + pairingPINChannel = null + } + } + } + + fun cancelPairing() { + runBlocking { + aapsLogger.debug(LTag.PUMP, "Cancelling pairing") + pairingJob?.cancelAndJoin() + aapsLogger.debug(LTag.PUMP, "Pairing cancelled") + } + } + + suspend fun providePairingPIN(pairingPIN: PairingPIN) { + try { + pairingPINChannel?.send(pairingPIN) + } catch (_: ClosedSendChannelException) { + } + } + + fun unpair() { + val bluetoothAddress = getBluetoothAddress() ?: return + + disconnectInternal(forceDisconnect = true) + + runBlocking { + try { + val pump = pumpManager?.acquirePump(bluetoothAddress) ?: return@runBlocking + pump.unpair() + pumpManager?.releasePump(bluetoothAddress) + } catch (_: ComboException) { + } catch (_: BluetoothException) { + } + } + + // Reset these states since they are associated + // with the now unpaired pump. + lastConnectionTimestamp = 0L + activeBasalProfile = null + lastActiveBasalProfileNumber = null + + // Reset the UI flows that are associated with the pump + // that just got unpaired to prevent the UI from showing + // information about that now-unpaired pump anymore. + _currentActivityUIFlow.value = noCurrentActivity() + _lastConnectionTimestampUIFlow.value = null + _batteryStateUIFlow.value = null + _reservoirLevelUIFlow.value = null + _lastBolusUIFlow.value = null + _baseBasalRateUIFlow.value = null + _serialNumberUIFlow.value = "" + _bluetoothAddressUIFlow.value = "" + } + + + /*** User interface flows ***/ + + // "UI flows" are hot flows that are meant to be used for showing + // information about the pump and its current state on the UI. + // These are kept in the actual plugin class to make sure they + // are always available, even if no pump is paired (which means + // that the "pump" variable is set to null and thus its flows + // are inaccessible). + // + // A few UI flows are internally also used for other checks, such + // as pairedStateUIFlow (which is used internally to verify whether + // or not the pump is paired). + // + // Some UI flows are nullable and have a null initial state to + // indicate to UIs that they haven't been filled with actual + // state yet. + + // This is a variant of driverStateFlow that retains the Error + // and Suspended state even after disconnecting to make sure these + // states kept being showed to the user post-disconnect. + private val _driverStateUIFlow = MutableStateFlow(DriverState.NotInitialized) + val driverStateUIFlow = _driverStateUIFlow.asStateFlow() + + // "Activity" is not to be confused with the Android Activity class. + // An "activity" is something that a command does, for example + // establishing a BT connection, or delivering a bolus, setting + // a basal rate factor, reading the current pump datetime etc. + data class CurrentActivityInfo(val description: String, val overallProgress: Double) + private fun noCurrentActivity() = CurrentActivityInfo("", 0.0) + private var _currentActivityUIFlow = MutableStateFlow(noCurrentActivity()) + val currentActivityUIFlow = _currentActivityUIFlow.asStateFlow() + + private var _lastConnectionTimestampUIFlow = MutableStateFlow(null) + val lastConnectionTimestampUIFlow = _lastConnectionTimestampUIFlow.asStateFlow() + + private var _batteryStateUIFlow = MutableStateFlow(null) + val batteryStateUIFlow = _batteryStateUIFlow.asStateFlow() + + data class ReservoirLevel(val state: ReservoirState, val availableUnits: Int) + private var _reservoirLevelUIFlow = MutableStateFlow(null) + val reservoirLevelUIFlow = _reservoirLevelUIFlow.asStateFlow() + + private var _lastBolusUIFlow = MutableStateFlow(null) + val lastBolusUIFlow = _lastBolusUIFlow.asStateFlow() + + private var _currentTbrUIFlow = MutableStateFlow(null) + val currentTbrUIFlow = _currentTbrUIFlow.asStateFlow() + + private var _baseBasalRateUIFlow = MutableStateFlow(null) + val baseBasalRateUIFlow = _baseBasalRateUIFlow.asStateFlow() + + private var _serialNumberUIFlow = MutableStateFlow("") + val serialNumberUIFlow = _serialNumberUIFlow.asStateFlow() + + private var _bluetoothAddressUIFlow = MutableStateFlow("") + val bluetoothAddressUIFlow = _bluetoothAddressUIFlow.asStateFlow() + + private var _pairedStateUIFlow = MutableStateFlow(false) + val pairedStateUIFlow = _pairedStateUIFlow.asStateFlow() + + // UI flow to show the current RT display frame on the UI. Unlike + // the other UI flows, this is a SharedFlow, not a StateFlow, + // since frames aren't "states", and StateFlow filters out duplicates + // (which isn't useful in this case). The flow is configured such + // that it never suspends; if its replay cache contains a frame already, + // that older frame is overwritten. This makes sure the flow always + // contains the current frame. + private var _displayFrameUIFlow = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val displayFrameUIFlow = _displayFrameUIFlow.asSharedFlow() + + + /*** Misc private functions ***/ + + private fun setupUiFlows() { + pumpUIFlowsDeferred = pumpCoroutineScope.async { + try { + coroutineScope { + pump!!.connectProgressFlow + .onEach { progressReport -> + val description = when (val progStage = progressReport.stage) { + is BasicProgressStage.EstablishingBtConnection -> + rh.gs( + R.string.combov2_establishing_bt_connection, + progStage.currentAttemptNr + ) + BasicProgressStage.PerformingConnectionHandshake -> rh.gs(R.string.combov2_pairing_performing_handshake) + else -> "" + } + _currentActivityUIFlow.value = CurrentActivityInfo( + description, + progressReport.overallProgress + ) + } + .launchIn(this) + + pump!!.setDateTimeProgressFlow + .onEach { progressReport -> + val description = when (progressReport.stage) { + RTCommandProgressStage.SettingDateTimeHour, + RTCommandProgressStage.SettingDateTimeMinute -> rh.gs(R.string.combov2_setting_current_pump_time) + RTCommandProgressStage.SettingDateTimeYear, + RTCommandProgressStage.SettingDateTimeMonth, + RTCommandProgressStage.SettingDateTimeDay -> rh.gs(R.string.combov2_setting_current_pump_date) + else -> "" + } + _currentActivityUIFlow.value = CurrentActivityInfo( + description, + progressReport.overallProgress + ) + } + .launchIn(this) + + pump!!.getBasalProfileFlow + .onEach { progressReport -> + val description = when (val stage = progressReport.stage) { + is RTCommandProgressStage.GettingBasalProfile -> + rh.gs(R.string.combov2_getting_basal_profile, stage.numSetFactors) + else -> "" + } + _currentActivityUIFlow.value = CurrentActivityInfo( + description, + progressReport.overallProgress + ) + } + .launchIn(this) + + pump!!.setBasalProfileFlow + .onEach { progressReport -> + val description = when (val stage = progressReport.stage) { + is RTCommandProgressStage.SettingBasalProfile -> + rh.gs(R.string.combov2_setting_basal_profile, stage.numSetFactors) + else -> "" + } + _currentActivityUIFlow.value = CurrentActivityInfo( + description, + progressReport.overallProgress + ) + } + .launchIn(this) + + pump!!.bolusDeliveryProgressFlow + .onEach { progressReport -> + val description = when (val stage = progressReport.stage) { + is RTCommandProgressStage.DeliveringBolus -> + rh.gs( + R.string.combov2_delivering_bolus, + stage.deliveredAmount.cctlBolusToIU(), + stage.totalAmount.cctlBolusToIU() + ) + else -> "" + } + _currentActivityUIFlow.value = CurrentActivityInfo( + description, + progressReport.overallProgress + ) + } + .launchIn(this) + + pump!!.parsedDisplayFrameFlow + .onEach { parsedDisplayFrame -> + _displayFrameUIFlow.emit( + parsedDisplayFrame?.displayFrame ?: NullDisplayFrame + ) + } + .launchIn(this) + + launch { + while (true) { + updateBaseBasalRateUI() + val currentMinute = DateTime().minuteOfHour().get() + + // Calculate how many minutes need to pass until we + // reach the next hour and thus the next basal profile + // factor becomes active. That way, the amount of UI + // refreshes is minimized. + // We cap the max waiting period to 58 minutes instead + // of 60 to allow for a small tolerance range for cases + // when this loop iterates exactly when the current hour + // is about to turn. + val minutesUntilNextFactor = max((58 - currentMinute), 0) + delay(minutesUntilNextFactor * 60 * 1000L) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + aapsLogger.error(LTag.PUMP, "Exception thrown in UI flows coroutine scope: $e") + throw e + } + } + } + + private fun updateBaseBasalRateUI() { + val currentHour = DateTime().hourOfDay().get() + // This sets value to null if no profile is set, + // which keeps the base basal rate on the UI blank. + _baseBasalRateUIFlow.value = activeBasalProfile?.get(currentHour)?.cctlBasalToIU() + } + + private fun handlePumpEvent(event: ComboCtlPump.Event) { + aapsLogger.debug(LTag.PUMP, "Handling pump event $event") + + when (event) { + is ComboCtlPump.Event.BatteryLow -> { + val notification = Notification( + Notification.COMBO_PUMP_ALARM, + text = rh.gs(R.string.combov2_battery_low_warning), + level = Notification.NORMAL + ) + rxBus.send(EventNewNotification(notification)) + } + + is ComboCtlPump.Event.ReservoirLow -> { + val notification = Notification( + Notification.COMBO_PUMP_ALARM, + text = rh.gs(R.string.combov2_reservoir_low_warning), + level = Notification.NORMAL + ) + rxBus.send(EventNewNotification(notification)) + } + + is ComboCtlPump.Event.QuickBolusInfused -> { + pumpSync.syncBolusWithPumpId( + event.timestamp.toEpochMilliseconds(), + event.bolusAmount.cctlBolusToIU(), + DetailedBolusInfo.BolusType.NORMAL, + event.bolusId, + PumpType.ACCU_CHEK_COMBO, + serialNumber() + ) + } + + is ComboCtlPump.Event.StandardBolusInfused -> { + val bolusType = when (event.standardBolusReason) { + ComboCtlPump.StandardBolusReason.NORMAL -> DetailedBolusInfo.BolusType.NORMAL + ComboCtlPump.StandardBolusReason.SUPERBOLUS -> DetailedBolusInfo.BolusType.SMB + ComboCtlPump.StandardBolusReason.PRIMING_INFUSION_SET -> DetailedBolusInfo.BolusType.PRIMING + } + pumpSync.syncBolusWithPumpId( + event.timestamp.toEpochMilliseconds(), + event.bolusAmount.cctlBolusToIU(), + bolusType, + event.bolusId, + PumpType.ACCU_CHEK_COMBO, + serialNumber() + ) + } + + is ComboCtlPump.Event.ExtendedBolusStarted -> { + pumpSync.syncExtendedBolusWithPumpId( + event.timestamp.toEpochMilliseconds(), + event.totalBolusAmount.cctlBolusToIU(), + event.totalDurationMinutes.toLong() * 60 * 1000, + false, + event.bolusId, + PumpType.ACCU_CHEK_COMBO, + serialNumber() + ) + } + + is ComboCtlPump.Event.ExtendedBolusEnded -> { + pumpSync.syncStopExtendedBolusWithPumpId( + event.timestamp.toEpochMilliseconds(), + event.bolusId, + PumpType.ACCU_CHEK_COMBO, + serialNumber() + ) + } + + is ComboCtlPump.Event.TbrStarted -> { + aapsLogger.debug(LTag.PUMP, "Pump reports TBR started; expected state according to AAPS: ${pumpSync.expectedPumpState()}") + val tbrStartTimestampInMs = event.tbr.timestamp.toEpochMilliseconds() + val tbrType = when (event.tbr.type) { + ComboCtlTbr.Type.NORMAL -> PumpSync.TemporaryBasalType.NORMAL + ComboCtlTbr.Type.EMULATED_100_PERCENT -> PumpSync.TemporaryBasalType.NORMAL + ComboCtlTbr.Type.SUPERBOLUS -> PumpSync.TemporaryBasalType.SUPERBOLUS + ComboCtlTbr.Type.EMULATED_COMBO_STOP -> PumpSync.TemporaryBasalType.EMULATED_PUMP_SUSPEND + ComboCtlTbr.Type.COMBO_STOPPED -> PumpSync.TemporaryBasalType.PUMP_SUSPEND + } + pumpSync.syncTemporaryBasalWithPumpId( + timestamp = tbrStartTimestampInMs, + rate = event.tbr.percentage.toDouble(), + duration = event.tbr.durationInMinutes.toLong() * 60 * 1000, + isAbsolute = false, + type = tbrType, + pumpId = tbrStartTimestampInMs, + pumpType = PumpType.ACCU_CHEK_COMBO, + pumpSerial = serialNumber() + ) + } + + is ComboCtlPump.Event.TbrEnded -> { + aapsLogger.debug(LTag.PUMP, "Pump reports TBR ended; expected state according to AAPS: ${pumpSync.expectedPumpState()}") + val tbrEndTimestampInMs = event.timestampWhenTbrEnded.toEpochMilliseconds() + pumpSync.syncStopTemporaryBasalWithPumpId( + timestamp = tbrEndTimestampInMs, + endPumpId = tbrEndTimestampInMs, + pumpType = PumpType.ACCU_CHEK_COMBO, + pumpSerial = serialNumber() + ) + } + + is ComboCtlPump.Event.UnknownTbrDetected -> { + // Inform about this unknown TBR that was observed (and automatically aborted). + val remainingDurationString = String.format( + "%02d:%02d", + event.remainingTbrDurationInMinutes / 60, + event.remainingTbrDurationInMinutes % 60 + ) + val notification = Notification( + Notification.COMBO_UNKNOWN_TBR, + text = rh.gs( + R.string.combov2_unknown_tbr_detected, + event.tbrPercentage, + remainingDurationString + ), + level = Notification.URGENT + ) + rxBus.send(EventNewNotification(notification)) + } + + else -> Unit + } + } + + // Marked as synchronized since this may get called by a finishing + // connect operation and by the command queue at the same time. + @Synchronized private fun disconnectInternal(forceDisconnect: Boolean) { + // Sometimes, the CommandQueue may decide to call disconnect while the + // driver is still busy with something, for example because some checks + // are being performed. Ignore disconnect requests in that case, unless + // the forceDisconnect flag is set. + if (!forceDisconnect && isBusy()) { + disconnectRequestPending = true + aapsLogger.debug(LTag.PUMP, "Ignoring disconnect request since driver is currently busy") + return + } + + if (isDisconnected()) { + aapsLogger.debug(LTag.PUMP, "Already disconnected") + return + } + + // It makes no sense to reach this location with pump + // being null due to the checks above. + assert(pump != null) + + // Run these operations in a coroutine to be able to wait + // until the disconnect really completes and the UI flows + // are all cancelled & their coroutines finished. Otherwise + // we can end up with race conditions because the coroutines + // are still ongoing in the background. + runBlocking { + // Disconnecting the pump needs to be done in one of two + // ways, depending on whether we try to disconnect while + // the pump is in the Connecting state or not: + // + // 1. Pump is in the Connecting state. A disconnectInternal() + // call then means that we are aborting the ongoing connect + // attempt. Internally, the pump may be waiting for a blocking + // Bluetooth device connect procedure to complete. + // 2. Pump is past the Connecting state. The blocking connect + // procedure is already over. + // + // In case #1, the internal IO loops inside the pump are not + // yet running. Also, connectionSetupJob.join() won't finish + // because of the blocking connect procedure. In this case, + // cancel that coroutine/Job, but don't join yet. Cancel, + // then disconnect the pump, then join. That way, the blocking + // Bluetooth connect procedure is aborted (closing a Bluetooth + // socket usually does that), the connectionSetupJob is unblocked, + // it can be canceled, and join() can finish. Since there is no + // IO coroutine running, there won't be any IO errors when + // disconnecting before joining connectionSetupJob. + // + // In case #2, the internal IO loops inside the pump *are* + // running, so disconnecting before joining is risky. Therefore, + // in this case, do cancel *and* join connectionSetupJob before + // actually disconnecting the pump. Otherwise, errors occur, since + // the connection setup code will try to communicate even though + // the Pump.disconnect() call shuts down the RFCOMM socket, + // making all send/receive calls fail. + + if (pump!!.stateFlow.value == ComboCtlPump.State.Connecting) { + // Case #1 from above + aapsLogger.debug(LTag.PUMP, "Cancelling ongoing connect attempt") + connectionSetupJob?.cancel() + pump?.disconnect() + connectionSetupJob?.join() + } else { + // Case #2 from above + aapsLogger.debug(LTag.PUMP, "Disconnecting Combo (if not disconnected already by a cancelling request)") + connectionSetupJob?.cancelAndJoin() + pump?.disconnect() + } + + aapsLogger.debug(LTag.PUMP, "Combo disconnected; cancelling UI flows coroutine") + pumpUIFlowsDeferred?.cancelAndJoin() + aapsLogger.debug(LTag.PUMP, "Cancelling state and status flows coroutine") + stateAndStatusFlowsDeferred?.cancelAndJoin() + aapsLogger.debug(LTag.PUMP, "Releasing pump instance back to pump manager") + + getBluetoothAddress()?.let { pumpManager?.releasePump(it) } + } + + connectionSetupJob = null + pumpUIFlowsDeferred = null + stateAndStatusFlowsDeferred = null + pump = null + + disconnectRequestPending = false + + aapsLogger.debug(LTag.PUMP, "Combo disconnect complete") + setDriverState(DriverState.Disconnected) + } + + private fun isPaired() = pairedStateUIFlow.value + + private fun updateComboCtlLogLevel() = + updateComboCtlLogLevel(sp.getBoolean(R.string.key_combov2_verbose_logging, false)) + + private fun updateComboCtlLogLevel(enableVerbose: Boolean) { + aapsLogger.debug(LTag.PUMP, "${if (enableVerbose) "Enabling" else "Disabling"} verbose logging") + ComboCtlLogger.threshold = if (enableVerbose) ComboCtlLogLevel.VERBOSE else ComboCtlLogLevel.DEBUG + } + + private fun setDriverState(newState: DriverState) { + val oldState = _driverStateFlow.value + + if (oldState == newState) + return + + // Update the last connection timestamp after executing a command. + // Other components like CommandReadStatus expect the lastDataTime() + // timestamp to be updated right after a command execution. + // As a special case, if all we did was to check the pump status, + // and afterwards disconnected, also update. The CheckingPump + // state masks multiple command executions. + if ((oldState is DriverState.ExecutingCommand) || ((oldState == DriverState.CheckingPump) && (newState == DriverState.Disconnected))) + updateLastConnectionTimestamp() + + // If the pump is suspended, or if an error occurred, we want + // to show the "suspended" and "error" state labels on the UI + // even after disconnecting. Otherwise, the user may not see + // that an error occurred or the pump is suspended. + val updateUIState = when (newState) { + DriverState.Disconnected -> { + when (driverStateUIFlow.value) { + DriverState.Error, + DriverState.Suspended -> false + else -> true + } + } + else -> true + } + if (updateUIState) { + _driverStateUIFlow.value = newState + + // Also show a notification to alert the user to the fact + // that the Combo is currently suspended, otherwise this + // only shows up in the Combo fragment. + if (newState == DriverState.Suspended) { + val notification = Notification( + Notification.COMBO_PUMP_SUSPENDED, + text = rh.gs(R.string.combov2_pump_is_suspended), + level = Notification.NORMAL + ) + rxBus.send(EventNewNotification(notification)) + } + } + + _driverStateFlow.value = newState + + if (newState == DriverState.Disconnected) + _currentActivityUIFlow.value = noCurrentActivity() + + aapsLogger.info(LTag.PUMP, "Setting Combo driver state: old: $oldState new: $newState") + + // TODO: Is it OK to send CONNECTED twice? It can happen when changing from Ready to Suspended. + when (newState) { + DriverState.Disconnected -> rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.DISCONNECTED)) + DriverState.Connecting -> rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTING)) + DriverState.Ready, + DriverState.Suspended -> rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTED)) + else -> Unit + } + } + + private fun executePendingDisconnect() { + if (!disconnectRequestPending) + return + + aapsLogger.debug(LTag.PUMP, "Executing pending disconnect request") + disconnectInternal(forceDisconnect = true) + } + + private fun unpairDueToPumpDataError() { + disconnectInternal(forceDisconnect = true) + val notification = Notification( + id = Notification.PUMP_ERROR, + date = dateUtil.now(), + text = rh.gs(R.string.combov2_cannot_access_pump_data), + level = Notification.URGENT, + validTo = 0 + ) + rxBus.send(EventNewNotification(notification)) + unpair() + } + + // Utility function to run a ComboCtlPump command (deliverBolus for example) + // and do common checks afterwards (like handling AlertScreenException). + // IMPORTANT: This disconnects in case of an error, so if any other + // nontrivial procedure needs to be done for the command in case of an + // error, do this inside a try-finally block in the block. + private suspend fun executeCommand( + block: suspend CoroutineScope.() -> Unit + ) { + try { + coroutineScope { + block.invoke(this) + } + + // The AAPS pump command queue may have asked for a disconnect + // while the command was being executed. Do this postponed + // disconnect now that we are done with the command. + executePendingDisconnect() + } catch (e: CancellationException) { + throw e + } catch (e: AlertScreenException) { + lastComboAlert = e.alertScreenContent + + notifyAboutComboAlert(e.alertScreenContent) + + // Disconnect since we are now in the Error state. + disconnectInternal(forceDisconnect = true) + + throw e + } catch (t: Throwable) { + // Disconnect since we are now in the Error state. + disconnectInternal(forceDisconnect = true) + throw t + } + } + + private fun updateLastConnectionTimestamp() { + lastConnectionTimestamp = System.currentTimeMillis() + _lastConnectionTimestampUIFlow.value = lastConnectionTimestamp + } + + private fun getAlertDescription(alert: AlertScreenContent) = + when (alert) { + is AlertScreenContent.Warning -> { + val desc = when (alert.code) { + 4 -> rh.gs(R.string.combov2_warning_4) + 10 -> rh.gs(R.string.combov2_warning_10) + else -> "" + } + + "${rh.gs(R.string.combov2_warning)} W${alert.code}" + + if (desc.isEmpty()) "" else ": $desc" + } + + is AlertScreenContent.Error -> { + val desc = when (alert.code) { + 1 -> rh.gs(R.string.combov2_error_1) + 2 -> rh.gs(R.string.combov2_error_2) + 4 -> rh.gs(R.string.combov2_error_4) + 5 -> rh.gs(R.string.combov2_error_5) + 6 -> rh.gs(R.string.combov2_error_6) + 7 -> rh.gs(R.string.combov2_error_7) + 8 -> rh.gs(R.string.combov2_error_8) + 9 -> rh.gs(R.string.combov2_error_9) + 10 -> rh.gs(R.string.combov2_error_10) + 11 -> rh.gs(R.string.combov2_error_11) + else -> "" + } + + "${rh.gs(R.string.combov2_error)} E${alert.code}" + + if (desc.isEmpty()) "" else ": $desc" + } + + else -> rh.gs(R.string.combov2_unrecognized_alert) + } + + private fun notifyAboutComboAlert(alert: AlertScreenContent) { + val notification = Notification( + Notification.COMBO_PUMP_ALARM, + text = "${rh.gs(R.string.combov2_combo_alert)}: ${getAlertDescription(alert)}", + level = if (alert is AlertScreenContent.Warning) Notification.NORMAL else Notification.URGENT + ) + rxBus.send(EventNewNotification(notification)) + } + + private fun reportFinishedBolus(status: String, pumpEnactResult: PumpEnactResult, succeeded: Boolean) { + val bolusingEvent = EventOverviewBolusProgress + bolusingEvent.status = status + bolusingEvent.percent = 100 + rxBus.send(bolusingEvent) + + pumpEnactResult.apply { + success = succeeded + comment = status + } + } + + private fun reportFinishedBolus(stringId: Int, pumpEnactResult: PumpEnactResult, succeeded: Boolean) = + reportFinishedBolus(rh.gs(stringId), pumpEnactResult, succeeded) + + private fun createFailurePumpEnactResult(comment: Int) = + PumpEnactResult(injector) + .success(false) + .enacted(false) + .comment(comment) + + private fun getBluetoothAddress(): ComboCtlBluetoothAddress? = + pumpManager!!.getPairedPumpAddresses().firstOrNull() + + private fun isDisconnected() = + when (driverStateFlow.value) { + DriverState.NotInitialized, + DriverState.Disconnected -> true + else -> false + } +} diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2RTDisplayFrameView.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2RTDisplayFrameView.kt new file mode 100644 index 0000000000..51c0c9fee3 --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/ComboV2RTDisplayFrameView.kt @@ -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 + } +} \ No newline at end of file diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/SPPumpStateStore.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/SPPumpStateStore.kt new file mode 100644 index 0000000000..95d9850766 --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/SPPumpStateStore.kt @@ -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" + } +} diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/Utility.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/Utility.kt new file mode 100644 index 0000000000..f97b87da22 --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/Utility.kt @@ -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) +} \ No newline at end of file diff --git a/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/activities/ComboV2PairingActivity.kt b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/activities/ComboV2PairingActivity.kt new file mode 100644 index 0000000000..b41f6d4bf7 --- /dev/null +++ b/pump/combov2/src/main/kotlin/info/nightscout/androidaps/plugins/pump/combov2/activities/ComboV2PairingActivity.kt @@ -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() + } +} diff --git a/pump/combov2/src/main/res/drawable/ic_combov2.xml b/pump/combov2/src/main/res/drawable/ic_combov2.xml new file mode 100644 index 0000000000..3f3647f097 --- /dev/null +++ b/pump/combov2/src/main/res/drawable/ic_combov2.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pump/combov2/src/main/res/layout/combov2_fragment.xml b/pump/combov2/src/main/res/layout/combov2_fragment.xml new file mode 100644 index 0000000000..c158844334 --- /dev/null +++ b/pump/combov2/src/main/res/layout/combov2_fragment.xml @@ -0,0 +1,456 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pump/combov2/src/main/res/layout/combov2_pairing_activity.xml b/pump/combov2/src/main/res/layout/combov2_pairing_activity.xml new file mode 100644 index 0000000000..8b899aa4de --- /dev/null +++ b/pump/combov2/src/main/res/layout/combov2_pairing_activity.xml @@ -0,0 +1,256 @@ + + + + + + + + + + + + +