Merge pull request #2322 from dv1/comboctl-dev

Carry over changes from comboctl ; disable pairing UI when BT permissions are not granted
This commit is contained in:
Milos Kozak 2023-01-03 15:01:54 +01:00 committed by GitHub
commit 5aacf04038
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 704 additions and 324 deletions

View file

@ -146,6 +146,10 @@ is much simpler, and builds ComboCtl as a kotlin-android project, not a Kotlin M
This simplifies integration into AndroidAPS, and avoids multiplatform problems (after all,
Kotlin Multiplatform is still marked as an alpha version feature).
The `comboctl/src/androidMain/AndroidManifest.xml` file also differs in that the `ComboCtl` version
contains `package="info.nightscout.comboctl.android"` in its `<manifest>` tag, while the AndroidAPS
version doesn't.
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

View file

@ -1,8 +1,5 @@
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
@ -12,11 +9,14 @@ 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 kotlinx.coroutines.Dispatchers
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import android.bluetooth.BluetoothAdapter as SystemBluetoothAdapter
import android.bluetooth.BluetoothDevice as SystemBluetoothDevice
import android.bluetooth.BluetoothSocket as SystemBluetoothSocket
private val logger = Logger.get("AndroidBluetoothDevice")

View file

@ -1,11 +1,6 @@
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
@ -20,6 +15,11 @@ import info.nightscout.comboctl.base.toBluetoothAddress
import java.io.IOException
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.thread
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
private val logger = Logger.get("AndroidBluetoothInterface")

View file

@ -394,21 +394,25 @@ object ApplicationLayer {
) : CMDHistoryEventDetail(isBolusDetail = true)
data class ExtendedBolusStarted(
val totalBolusAmount: Int,
val totalDurationMinutes: Int
val totalDurationMinutes: Int,
val manual: Boolean
) : CMDHistoryEventDetail(isBolusDetail = true)
data class ExtendedBolusEnded(
val totalBolusAmount: Int,
val totalDurationMinutes: Int
val totalDurationMinutes: Int,
val manual: Boolean
) : CMDHistoryEventDetail(isBolusDetail = true)
data class MultiwaveBolusStarted(
val totalBolusAmount: Int,
val immediateBolusAmount: Int,
val totalDurationMinutes: Int
val totalDurationMinutes: Int,
val manual: Boolean
) : CMDHistoryEventDetail(isBolusDetail = true)
data class MultiwaveBolusEnded(
val totalBolusAmount: Int,
val immediateBolusAmount: Int,
val totalDurationMinutes: Int
val totalDurationMinutes: Int,
val manual: Boolean
) : CMDHistoryEventDetail(isBolusDetail = true)
data class NewDateTimeSet(val dateTime: LocalDateTime) : CMDHistoryEventDetail(isBolusDetail = false)
}
@ -524,14 +528,18 @@ object ApplicationLayer {
)
/**
* Possible bolus types used in COMMAND mode commands.
* Possible immediate bolus types used in COMMAND mode commands.
*
* "Immediate" means that the bolus gets delivered immediately once the command is sent.
* A standard bolus only has an immediate delivery, an extended bolus has none, and
* a multiwave bolus is partially made up of an immediate and an extended delivery.
*/
enum class CMDBolusType(val id: Int) {
enum class CMDImmediateBolusType(val id: Int) {
STANDARD(0x47),
MULTI_WAVE(0xB7);
companion object {
private val values = CMDBolusType.values()
private val values = CMDImmediateBolusType.values()
fun fromInt(value: Int) = values.firstOrNull { it.id == value }
}
}
@ -565,7 +573,7 @@ object ApplicationLayer {
* "57" means 5.7 IU.
*/
data class CMDBolusDeliveryStatus(
val bolusType: CMDBolusType,
val bolusType: CMDImmediateBolusType,
val deliveryState: CMDBolusDeliveryState,
val remainingAmount: Int
)
@ -996,8 +1004,18 @@ object ApplicationLayer {
command = Command.CMD_GET_BOLUS_STATUS
)
enum class CMDDeliverBolusType {
STANDARD_BOLUS,
EXTENDED_BOLUS,
MULTIWAVE_BOLUS
}
/**
* Creates a CMD_DELIVER_BOLUS packet.
* Creates a CMD_DELIVER_BOLUS packet for a standard bolus.
*
* This is equivalent to calling the full [createCMDDeliverBolusPacket] function
* with bolusType set to [CMDDeliverBolusType.STANDARD_BOLUS]. The [bolusAmount]
* argument here is passed as the full function's totalBolusAmount argument.
*
* The command mode must have been activated before this can be sent to the Combo.
*
@ -1008,45 +1026,124 @@ object ApplicationLayer {
* "57" means 5.7 IU.
* @return The produced packet.
*/
fun createCMDDeliverBolusPacket(bolusAmount: Int): Packet {
fun createCMDDeliverBolusPacket(bolusAmount: Int) =
createCMDDeliverBolusPacket(
totalBolusAmount = bolusAmount,
immediateBolusAmount = 0,
durationInMinutes = 0,
bolusType = CMDDeliverBolusType.STANDARD_BOLUS
)
/**
* 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 totalBolusAmount Total 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.
* @param immediateBolusAmount The amount of insulin units to use for the
* immediate portion of a multiwave bolus. This value is only used if
* [bolusType] is [CMDDeliverBolusType.MULTIWAVE_BOLUS]. This
* value must be <= [totalBolusAmount].
* @param durationInMinutes The duration of the extended bolus or the
* extended portion of the multiwave bolus. If [bolusType] is set to
* [CMDDeliverBolusType.STANDARD_BOLUS], this value is ignored.
* Otherwise, it must be at least 15.
* @param bolusType Type of the bolus.
* @return The produced packet.
* @throws IllegalArgumentException if [immediateBolusAmount] exceeds
* [totalBolusAmount], or if [durationInMinutes] is <15 when [bolusType]
* is set to anything other than [CMDDeliverBolusType.STANDARD_BOLUS].
*/
fun createCMDDeliverBolusPacket(
totalBolusAmount: Int,
immediateBolusAmount: Int,
durationInMinutes: Int,
bolusType: CMDDeliverBolusType
): Packet {
// Values that aren't used for the particular bolus type are set to 0
// since we don't know what happens if we transmit a nonzero value to
// the Combo with these bolus types.
val (effectiveImmediateBolusAmount, effectiveDurationInMinutes) = when (bolusType) {
CMDDeliverBolusType.STANDARD_BOLUS -> Pair(0, 0)
CMDDeliverBolusType.EXTENDED_BOLUS -> Pair(0, durationInMinutes)
CMDDeliverBolusType.MULTIWAVE_BOLUS -> Pair(immediateBolusAmount, durationInMinutes)
}
// Apply argument requirement checks, depending on the bolus type.
when (bolusType) {
CMDDeliverBolusType.STANDARD_BOLUS -> Unit
CMDDeliverBolusType.EXTENDED_BOLUS ->
require(effectiveDurationInMinutes >= 15) {
"extended bolus duration must be at least 15; actual duration: $effectiveDurationInMinutes"
}
CMDDeliverBolusType.MULTIWAVE_BOLUS -> {
require(effectiveDurationInMinutes >= 15) {
"multiwave bolus duration must be at least 15; actual duration: $effectiveDurationInMinutes"
}
require(immediateBolusAmount <= totalBolusAmount) {
"immediate bolus duration must be <= total bolus amount; actual immediate/total amount: " +
"$effectiveImmediateBolusAmount / $totalBolusAmount"
}
}
}
// 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()
val totalBolusAmountAsFloatBits = totalBolusAmount.toFloat().toBits().toPosLong()
val effectiveImmediateBolusAmountAsFloatBits = effectiveImmediateBolusAmount.toFloat().toBits().toPosLong()
val effectiveDurationInMinutesAsFloatBits = effectiveDurationInMinutes.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.
// TODO: It is currently unknown why the same bolus parameters have to
// be added twice (once as 16-bit integers and once as 32-bit floats).
// NOTE: The 0x55, 0x59, 0x65 etc. values have been found empirically.
val bolusTypeIDBytes = when (bolusType) {
CMDDeliverBolusType.STANDARD_BOLUS -> intArrayOf(0x55, 0x59)
CMDDeliverBolusType.EXTENDED_BOLUS -> intArrayOf(0x65, 0x69)
CMDDeliverBolusType.MULTIWAVE_BOLUS -> intArrayOf(0xA5, 0xA9)
}
val payload = byteArrayListOfInts(
// This specifies a standard bolus.
0x55, 0x59,
bolusTypeIDBytes[0], bolusTypeIDBytes[1],
// Total bolus amount, encoded as a 16-bit little endian integer.
(bolusAmount and 0x00FF) ushr 0,
(bolusAmount and 0xFF00) ushr 8,
(totalBolusAmount and 0x00FF) ushr 0,
(totalBolusAmount 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,
(effectiveDurationInMinutes and 0x00FF) ushr 0,
(effectiveDurationInMinutes and 0xFF00) ushr 8,
// Immediate bolus amount encoded as a 16-bit little endian integer.
// (Only relevant for multi-wave bolus.)
0x00, 0x00,
(effectiveImmediateBolusAmount and 0x00FF) ushr 0,
(effectiveImmediateBolusAmount and 0xFF00) ushr 8,
// 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(),
((totalBolusAmountAsFloatBits and 0x000000FFL) ushr 0).toInt(),
((totalBolusAmountAsFloatBits and 0x0000FF00L) ushr 8).toInt(),
((totalBolusAmountAsFloatBits and 0x00FF0000L) ushr 16).toInt(),
((totalBolusAmountAsFloatBits 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,
((effectiveDurationInMinutesAsFloatBits and 0x000000FFL) ushr 0).toInt(),
((effectiveDurationInMinutesAsFloatBits and 0x0000FF00L) ushr 8).toInt(),
((effectiveDurationInMinutesAsFloatBits and 0x00FF0000L) ushr 16).toInt(),
((effectiveDurationInMinutesAsFloatBits and 0xFF000000L) ushr 24).toInt(),
// Immediate bolus amount encoded as a 32-bit little endian float point.
// (Only relevant for multi-wave bolus.)
0x00, 0x00, 0x00, 0x00
((effectiveImmediateBolusAmountAsFloatBits and 0x000000FFL) ushr 0).toInt(),
((effectiveImmediateBolusAmountAsFloatBits and 0x0000FF00L) ushr 8).toInt(),
((effectiveImmediateBolusAmountAsFloatBits and 0x00FF0000L) ushr 16).toInt(),
((effectiveImmediateBolusAmountAsFloatBits and 0xFF000000L) ushr 24).toInt(),
)
// Add a CRC16 checksum for all of the parameters
// Add a CRC16 checksum for all the parameters
// stored in the payload above.
val crcChecksum = calculateCRC16MCRF4XX(payload)
payload.add(((crcChecksum and 0x00FF) ushr 0).toByte())
@ -1068,7 +1165,7 @@ object ApplicationLayer {
* @param bolusType The type of the bolus to cancel.
* @return The produced packet.
*/
fun createCMDCancelBolusPacket(bolusType: CMDBolusType) = Packet(
fun createCMDCancelBolusPacket(bolusType: CMDImmediateBolusType) = Packet(
command = Command.CMD_CANCEL_BOLUS,
payload = byteArrayListOfInts(bolusType.id)
)
@ -1289,6 +1386,11 @@ object ApplicationLayer {
// 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.
// 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 eventDetail = when (eventTypeId) {
// Quick bolus.
4, 5 -> {
@ -1313,16 +1415,18 @@ object ApplicationLayer {
}
// Extended bolus.
8, 9 -> {
8, 9, 16, 17 -> {
// 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)
// Event type IDs 8 or 16 = bolus started. IDs 9 or 17 = bolus ended.
val started = (eventTypeId == 8) || (eventTypeId == 16)
val manual = (eventTypeId == 8) || (eventTypeId == 9)
logger(LogLevel.DEBUG) {
"Detail info: got history event \"extended bolus ${if (started) "started" else "ended"}\" " +
"Detail info: got history event \"${if (manual) "manual" else "automatic"} " +
"extended bolus ${if (started) "started" else "ended"}\" " +
"with total amount of ${totalBolusAmount.toFloat() / 10} IU and " +
"total duration of $totalDurationMinutes minutes"
}
@ -1330,17 +1434,19 @@ object ApplicationLayer {
if (started)
CMDHistoryEventDetail.ExtendedBolusStarted(
totalBolusAmount = totalBolusAmount,
totalDurationMinutes = totalDurationMinutes
totalDurationMinutes = totalDurationMinutes,
manual = manual
)
else
CMDHistoryEventDetail.ExtendedBolusEnded(
totalBolusAmount = totalBolusAmount,
totalDurationMinutes = totalDurationMinutes
totalDurationMinutes = totalDurationMinutes,
manual = manual
)
}
// Multiwave bolus.
10, 11 -> {
10, 11, 18, 19 -> {
// 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.
@ -1349,11 +1455,13 @@ object ApplicationLayer {
((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)
// Event type IDs 10 or 18 = bolus started. IDs 11 or 19 = bolus ended.
val started = (eventTypeId == 10) || (eventTypeId == 18)
val manual = (eventTypeId == 10) || (eventTypeId == 11)
logger(LogLevel.DEBUG) {
"Detail info: got history event \"multiwave bolus ${if (started) "started" else "ended"}\" " +
"Detail info: got history event \"${if (manual) "manual" else "automatic"} " +
"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"
@ -1363,13 +1471,15 @@ object ApplicationLayer {
CMDHistoryEventDetail.MultiwaveBolusStarted(
totalBolusAmount = totalBolusAmount,
immediateBolusAmount = immediateBolusAmount,
totalDurationMinutes = totalDurationMinutes
totalDurationMinutes = totalDurationMinutes,
manual = manual
)
else
CMDHistoryEventDetail.MultiwaveBolusEnded(
totalBolusAmount = totalBolusAmount,
immediateBolusAmount = immediateBolusAmount,
totalDurationMinutes = totalDurationMinutes
totalDurationMinutes = totalDurationMinutes,
manual = manual
)
}
@ -1378,10 +1488,6 @@ object ApplicationLayer {
// 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).
@ -1477,7 +1583,7 @@ object ApplicationLayer {
val payload = packet.payload
val bolusTypeInt = payload[2].toPosInt()
val bolusType = CMDBolusType.fromInt(bolusTypeInt)
val bolusType = CMDImmediateBolusType.fromInt(bolusTypeInt)
?: throw PayloadDataCorruptionException(
packet,
"Invalid bolus type ${bolusTypeInt.toHexString(2, true)}"

View file

@ -1,8 +1,8 @@
package info.nightscout.comboctl.base
import kotlin.reflect.KClassifier
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.reflect.KClassifier
private val logger = Logger.get("Pump")

View file

@ -1012,11 +1012,29 @@ class PumpIO(
* 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 =
suspend fun deliverCMDStandardBolus(totalBolusAmount: Int): Boolean =
deliverCMDStandardBolus(
totalBolusAmount,
immediateBolusAmount = 0,
durationInMinutes = 0,
bolusType = ApplicationLayer.CMDDeliverBolusType.STANDARD_BOLUS
)
suspend fun deliverCMDStandardBolus(
totalBolusAmount: Int,
immediateBolusAmount: Int,
durationInMinutes: Int,
bolusType: ApplicationLayer.CMDDeliverBolusType
): Boolean =
runPumpIOCall("deliver standard bolus", Mode.COMMAND) {
val packet = sendPacketWithResponse(
ApplicationLayer.createCMDDeliverBolusPacket(bolusAmount),
ApplicationLayer.createCMDDeliverBolusPacket(
totalBolusAmount,
immediateBolusAmount,
durationInMinutes,
bolusType
),
ApplicationLayer.Command.CMD_DELIVER_BOLUS_RESPONSE
)
@ -1038,7 +1056,7 @@ class PumpIO(
// TODO: Test that this function does the expected thing
// when no bolus is actually ongoing.
val packet = sendPacketWithResponse(
ApplicationLayer.createCMDCancelBolusPacket(ApplicationLayer.CMDBolusType.STANDARD),
ApplicationLayer.createCMDCancelBolusPacket(ApplicationLayer.CMDImmediateBolusType.STANDARD),
ApplicationLayer.Command.CMD_CANCEL_BOLUS_RESPONSE
)

View file

@ -1,11 +1,11 @@
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
import kotlin.math.max
import kotlin.math.min
// 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

View file

@ -1,6 +1,7 @@
package info.nightscout.comboctl.main
import info.nightscout.comboctl.base.ApplicationLayer
import info.nightscout.comboctl.base.ApplicationLayer.CMDDeliverBolusType
import info.nightscout.comboctl.base.ApplicationLayer.CMDHistoryEventDetail
import info.nightscout.comboctl.base.BasicProgressStage
import info.nightscout.comboctl.base.BluetoothAddress
@ -29,10 +30,6 @@ 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
@ -53,6 +50,10 @@ import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.absoluteValue
import kotlin.time.Duration
import kotlin.time.DurationUnit
import kotlin.time.toDuration
private val logger = Logger.get("Pump")
@ -101,11 +102,11 @@ object RTCommandProgressStage {
*
* 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.
* @property deliveredImmediateAmount How many units have been delivered so far.
* This is always <= totalAmount.
* @property totalAmount Total amount of bolus units.
* @property totalImmediateAmount Total amount of bolus units.
*/
data class DeliveringBolus(val deliveredAmount: Int, val totalAmount: Int) : ProgressStage("deliveringBolus")
data class DeliveringBolus(val deliveredImmediateAmount: Int, val totalImmediateAmount: Int) : ProgressStage("deliveringBolus")
/**
* TDD fetching history stage.
@ -297,7 +298,7 @@ class Pump(
BasicProgressStage.Finished,
is BasicProgressStage.Aborted -> 1.0
is RTCommandProgressStage.DeliveringBolus ->
stage.deliveredAmount.toDouble() / stage.totalAmount.toDouble()
stage.deliveredImmediateAmount.toDouble() / stage.totalImmediateAmount.toDouble()
else -> 0.0
}
}
@ -369,8 +370,11 @@ class Pump(
val force100Percent: Boolean
) : CommandDescription()
class DeliveringBolusCommandDesc(
val bolusAmount: Int,
val bolusReason: StandardBolusReason
val totalBolusAmount: Int,
val immediateBolusAmount: Int,
val durationInMinutes: Int,
val standardBolusReason: StandardBolusReason,
val bolusType: ApplicationLayer.CMDDeliverBolusType
) : CommandDescription()
/**
@ -410,27 +414,27 @@ class Pump(
/**
* 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.
* @param deliveredImmediateAmount Bolus amount that was delivered before the bolus was cancelled. In 0.1 IU units.
* @param totalImmediateAmount Total bolus amount that was supposed to be delivered. In 0.1 IU units.
*/
class BolusCancelledByUserException(val deliveredAmount: Int, totalAmount: Int) :
class BolusCancelledByUserException(val deliveredImmediateAmount: Int, totalImmediateAmount: Int) :
BolusDeliveryException(
totalAmount,
"Bolus cancelled (delivered amount: ${deliveredAmount.toStringWithDecimal(1)} IU " +
"total programmed amount: ${totalAmount.toStringWithDecimal(1)} IU"
totalImmediateAmount,
"Bolus cancelled (delivered amount: ${deliveredImmediateAmount.toStringWithDecimal(1)} IU " +
"total programmed amount: ${totalImmediateAmount.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.
* @param deliveredImmediateAmount Bolus amount that was delivered before the bolus was aborted. In 0.1 IU units.
* @param totalImmediateAmount Total bolus amount that was supposed to be delivered.
*/
class BolusAbortedDueToErrorException(deliveredAmount: Int, totalAmount: Int) :
class BolusAbortedDueToErrorException(deliveredImmediateAmount: Int, totalImmediateAmount: Int) :
BolusDeliveryException(
totalAmount,
"Bolus aborted due to an error (delivered amount: ${deliveredAmount.toStringWithDecimal(1)} IU " +
"total programmed amount: ${totalAmount.toStringWithDecimal(1)} IU"
totalImmediateAmount,
"Bolus aborted due to an error (delivered amount: ${deliveredImmediateAmount.toStringWithDecimal(1)} IU " +
"total programmed amount: ${totalImmediateAmount.toStringWithDecimal(1)} IU"
)
/**
@ -454,27 +458,25 @@ class Pump(
*
* If no TBR is active, [actualTbrDuration] is 0. If no TBR was expected to be active,
* [expectedTbrDuration] is 0.
*
* [actualTbrPercentage] and [actualTbrDuration] are both null if a multiwave or extended
* bolus is active because the exact TBR percentage / duration are not shown on screen then.
*/
class UnexpectedTbrStateException(
val expectedTbrPercentage: Int,
val expectedTbrDuration: Int,
val actualTbrPercentage: Int,
val actualTbrDuration: Int
val actualTbrPercentage: Int?,
val actualTbrDuration: Int?
) : ComboException(
if (actualTbrPercentage != null)
"Expected TBR: $expectedTbrPercentage% $expectedTbrDuration minutes ; " +
"actual TBR: $actualTbrPercentage% $actualTbrDuration minutes"
else if (expectedTbrPercentage == 100)
"Did not expect a TBR during active extended/multiwave bolus, observed one"
else
"Expected a TBR during active extended/multiwave bolus, did not observe one"
)
/**
* 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.
*
@ -894,8 +896,6 @@ class Pump(
* @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" }
@ -933,7 +933,6 @@ class Pump(
// 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"))
@ -1233,10 +1232,6 @@ class Pump(
* @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
@ -1306,7 +1301,7 @@ class Pump(
}
} else {
// Current status shows that there is no TBR ongoing. This is
// therefore a redunant call. Handle this by expecting a 100%
// therefore a redundant call. Handle this by expecting a 100%
// basal rate to make sure the checks below don't throw anything.
expectedTbrPercentage = 100
expectedTbrDuration = 0
@ -1337,56 +1332,77 @@ class Pump(
is ParsedScreen.MainScreen -> mainScreen.content
else -> throw NoUsableRTScreenException()
}
val (actualTbrPercentage, actualTbrDuration) = when (mainScreenContent) {
is MainScreenContent.Stopped ->
// This should never be reached. The Combo can switch to the Stopped
// state on its own, but only if an error occurs, and errors are
// already caught by ParsedDisplayFrameStream.getParsedDisplayFrame().
throw IllegalStateException("Combo is in the stopped state after setting TBR")
is MainScreenContent.ExtendedOrMultiwaveBolus -> {
if (mainScreenContent.tbrIsActive) {
// We have to go into the TBR menu to get details about the active TBR;
// the main screen does not show them when an extended/multiwave bolus
// is currently ongoing.
lookupActiveTbrDetails()
} else {
Pair(100, 0)
}
}
is MainScreenContent.Normal ->
Pair(100, 0)
is MainScreenContent.Tbr ->
Pair(mainScreenContent.tbrPercentage, mainScreenContent.remainingTbrDurationInMinutes)
}
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)
val tbrVisibleOnMainScreen = when (mainScreenContent) {
is MainScreenContent.Tbr -> true
is MainScreenContent.ExtendedOrMultiwaveBolus -> mainScreenContent.tbrIsActive
else -> false
}
is MainScreenContent.Normal -> {
if ((expectedTbrPercentage != 100) && (expectedTbrDuration >= 2)) {
// We expected a TBR to be active, but there isn't any;
// we aren't seen any TBR main screen contents.
// Only consider this an error if the duration is >2 minutes.
// Otherwise, this was a TBR that was about to end, so it
// might have ended while these checks here were running.
// Verify that the TBR state is OK according to these criteria:
//
// 1. TBR percentages match and the different between expected and actual duration is <= 4 minutes.
// (Allow for the 4-minute tolerance since a little while may have passed between setting the
// TBR and reaching this location in the code.)
// 2. We expected a TBR to be active, but the main screen shows no TBR. If the expected TBR
// duration is <2 minutes, then this is still considered OK. That's because the TBR might
// have been one that was about to end, so while this code was ongoing, it may have ended.
//
// In any other case, assume that the TBR that is active is not OK.
val tbrStateIsOk =
if ((expectedTbrPercentage == actualTbrPercentage) && ((expectedTbrDuration - actualTbrDuration).absoluteValue <= 4)) {
logger(LogLevel.DEBUG) { "TBR percentages and durations match" }
true
} else if (!tbrVisibleOnMainScreen && (expectedTbrPercentage != 100) && (expectedTbrDuration < 2)) {
logger(LogLevel.DEBUG) { "Almost-expired TBR no longer visible on screen; assumed to have ended in the meantime" }
true
} else {
logger(LogLevel.ERROR) {
"Mismatch between expected TBR and actually active TBR; " +
"expected TBR percentage / duration: $expectedTbrPercentage / $expectedTbrDuration; " +
"actual TBR: percentage / remaining duration: $actualTbrPercentage / $actualTbrDuration"
}
false
}
if (!tbrStateIsOk) {
throw UnexpectedTbrStateException(
expectedTbrPercentage = expectedTbrPercentage,
expectedTbrDuration = expectedTbrDuration,
actualTbrPercentage = 100,
actualTbrDuration = 0
actualTbrPercentage = actualTbrPercentage,
actualTbrDuration = actualTbrDuration
)
}
}
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
)
}
}
}
return@executeCommand result
}
@ -1397,51 +1413,94 @@ class Pump(
val bolusDeliveryProgressFlow = bolusDeliveryProgressReporter.progressFlow
/**
* Instructs the pump to deliver the specified bolus amount.
* Instructs the pump to deliver a standard bolus with the specified 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.
* This is equivalent to calling the full [deliverBolus] function with the bolus
* type set to [ApplicationLayer.CMDDeliverBolusType.STANDARD_BOLUS], a total
* bolus amount that is set to [bolusAmount], and the immediate amount and
* duration both set to 0.
*
* 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 bolusAmount Amount of insulin units the standard bolus shall deliver.
* @param bolusReason Reason for this standard bolus.
* @param bolusStatusUpdateIntervalInMs Interval between status updates,
* in milliseconds. Must be at least 1
*/
suspend fun deliverBolus(
bolusAmount: Int,
bolusReason: StandardBolusReason,
bolusStatusUpdateIntervalInMs: Long = 250
) = deliverBolus(
totalBolusAmount = bolusAmount,
immediateBolusAmount = 0,
durationInMinutes = 0,
standardBolusReason = bolusReason,
bolusType = ApplicationLayer.CMDDeliverBolusType.STANDARD_BOLUS,
bolusStatusUpdateIntervalInMs = bolusStatusUpdateIntervalInMs
)
/**
* Instructs the pump to deliver a bolus.
*
* The function suspends until the immediate portion of the bolus was fully delivered,
* or when an error occurred. In the latter case, an exception is thrown.
*
* The bolus delivey is split in two parts: the immediate portion and the extended
* portion. The immediate portion is infused right away, as fast as the Combo is able
* to do so. The extended portion is delivered over the specified [durationInMinutes],
* and behaves much like a TBR.
*
* During the delivery of the immediate portion, 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 whethr 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 immediate delivery of the bolus, simply cancel the coroutine that is
* suspended by this function.
*
* IMPORTANT: The extended portion _cannot_ be canceled that way; there is no way of
* doing that other than for the Combo to be stopped and started again.
*
* Prior to the delivery, the number of units available in the reservoir is checked
* by looking at [statusFlow] and compared against [totalBolusAmount]. 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
* or started (depending on the bolus type). The details in that history delta entry
* are then emitted via [onEvent] as [Event.StandardBolusInfused] for a standard bolus.
* Extended boluses generate [Event.ExtendedBolusStarted]. Likewise, multiwave boluses
* generate [Event.MultiwaveBolusStarted].
*
* If there is no entry, [BolusNotDeliveredException] is thrown. If a standard bolus
* was delivered, and more than one bolus entry is detected, [UnaccountedBolusDetectedException]
* is thrown. 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_ an immediate delivery, then some insulin might have still been
* delivered, and there will be a corresponding 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 with [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 the reservoir level is given inwhole IU units).
*
* @param totalBolusAmount Total bolus amount to deliver (that is, the sum of the immediate
* and extended portions). 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 immediateBolusAmount The amount to deliver immediately. This is only used
* [bolusType] is [CMDDeliverBolusType.MULTIWAVE_BOLUS], and is ignored otherwise.
* When delivering a multiwave bolus, this value must be >= 1 and < [totalBolusAmount].
* @param durationInMinutes The duration of the extended bolus or the extended portion
* of the multiwave bolus. If [bolusType] is set to [CMDDeliverBolusType.STANDARD_BOLUS],
* this value is ignored. Otherwise, it must be at least 15. Maximum possible value
* is 720 (= 12 hours).
* @param standardBolusReason Reason for the standard bolus. If [bolusType] is not
* [CMDDeliverBolusType.STANDARD_BOLUS], this value is ignored.
* @param bolusType Type of the 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.
@ -1451,33 +1510,77 @@ class Pump(
* 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 IllegalArgumentException if [totalBolusAmount] is not in the 0-250 range, or
* if [bolusStatusUpdateIntervalInMs] is less than 1, or if [immediateBolusAmount] exceeds
* [totalBolusAmount] when delivering a multiwave bolus, or if [durationInMinutes] is <15
* when [bolusType] is set to anything other than [CMDDeliverBolusType.STANDARD_BOLUS].
* @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(
suspend fun deliverBolus(
totalBolusAmount: Int,
immediateBolusAmount: Int,
durationInMinutes: Int,
standardBolusReason: StandardBolusReason,
bolusType: ApplicationLayer.CMDDeliverBolusType,
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)
description = DeliveringBolusCommandDesc(
totalBolusAmount,
immediateBolusAmount,
durationInMinutes,
standardBolusReason,
bolusType
)
) {
require((bolusAmount > 0) && (bolusAmount <= 250)) {
"Invalid bolus amount $bolusAmount (${bolusAmount.toStringWithDecimal(1)} IU)"
require((totalBolusAmount > 0) && (totalBolusAmount <= 250)) {
"Invalid bolus amount $totalBolusAmount (${totalBolusAmount.toStringWithDecimal(1)} IU)"
}
require(bolusStatusUpdateIntervalInMs >= 1) {
"Invalid bolus status update interval $bolusStatusUpdateIntervalInMs"
}
when (bolusType) {
CMDDeliverBolusType.STANDARD_BOLUS -> Unit
CMDDeliverBolusType.EXTENDED_BOLUS ->
require(
(durationInMinutes >= 15) &&
(durationInMinutes <= 720) &&
((durationInMinutes % 15) == 0)
) {
"extended bolus duration must be in the 15-720 range and an integer multiple of 15; " +
"actual duration: $durationInMinutes"
}
CMDDeliverBolusType.MULTIWAVE_BOLUS -> {
require(
(durationInMinutes >= 15) &&
(durationInMinutes <= 720) &&
((durationInMinutes % 15) == 0)
) {
"multiwave bolus duration must be in the 15-720 range and an integer multiple of 15; " +
"actual duration: $durationInMinutes"
}
require(immediateBolusAmount >= 1) {
"immediate bolus portion of multiwave bolus must be at least 0.1 IU; actual" +
"amount: ${immediateBolusAmount.toStringWithDecimal(1)}"
}
require(immediateBolusAmount < totalBolusAmount) {
"immediate bolus duration must be < total bolus amount; actual immediate/total " +
"amount: ${immediateBolusAmount.toStringWithDecimal(1)}" +
" / ${totalBolusAmount.toStringWithDecimal(1)}"
}
}
}
// 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
@ -1488,25 +1591,30 @@ class Pump(
// 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
val roundedBolusIU = (totalBolusAmount + 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" +
"${status.availableUnitsInReservoir} IU; bolus amount: ${totalBolusAmount.toStringWithDecimal(1)} IU" +
"(rounded: $roundedBolusIU IU)"
}
if (status.availableUnitsInReservoir < roundedBolusIU)
throw InsufficientInsulinAvailableException(bolusAmount, status.availableUnitsInReservoir)
throw InsufficientInsulinAvailableException(totalBolusAmount, 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)
logger(LogLevel.DEBUG) { "Beginning bolus delivery of ${totalBolusAmount.toStringWithDecimal(1)} IU" }
val didDeliver = pumpIO.deliverCMDStandardBolus(
totalBolusAmount,
immediateBolusAmount,
durationInMinutes,
bolusType
)
if (!didDeliver) {
logger(LogLevel.ERROR) { "Bolus delivery did not commence" }
throw BolusNotDeliveredException(bolusAmount)
throw BolusNotDeliveredException(totalBolusAmount)
}
bolusDeliveryProgressReporter.reset(Unit)
@ -1515,10 +1623,19 @@ class Pump(
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 {
// The Combo does not send immediate bolus delivery 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. Only do this for standard and multiwave boluses, since the extended
// bolus has no immediate delivery portion.
if (bolusType != CMDDeliverBolusType.EXTENDED_BOLUS) {
val expectedImmediateAmount = if (bolusType == CMDDeliverBolusType.STANDARD_BOLUS)
totalBolusAmount
else
immediateBolusAmount
while (true) {
delay(bolusStatusUpdateIntervalInMs)
@ -1527,37 +1644,40 @@ class Pump(
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.DELIVERING -> expectedImmediateAmount - status.remainingAmount
ApplicationLayer.CMDBolusDeliveryState.DELIVERED -> expectedImmediateAmount
ApplicationLayer.CMDBolusDeliveryState.CANCELLED_BY_USER -> {
logger(LogLevel.DEBUG) { "Bolus cancelled by user" }
throw BolusCancelledByUserException(
deliveredAmount = bolusAmount - status.remainingAmount,
totalAmount = bolusAmount
deliveredImmediateAmount = expectedImmediateAmount - status.remainingAmount,
totalImmediateAmount = expectedImmediateAmount
)
}
ApplicationLayer.CMDBolusDeliveryState.ABORTED_DUE_TO_ERROR -> {
logger(LogLevel.ERROR) { "Bolus aborted due to a delivery error" }
throw BolusAbortedDueToErrorException(
deliveredAmount = bolusAmount - status.remainingAmount,
totalAmount = bolusAmount
deliveredImmediateAmount = expectedImmediateAmount - status.remainingAmount,
totalImmediateAmount = expectedImmediateAmount
)
}
else -> continue
}
bolusDeliveryProgressReporter.setCurrentProgressStage(
RTCommandProgressStage.DeliveringBolus(
deliveredAmount = deliveredAmount,
totalAmount = bolusAmount
deliveredImmediateAmount = deliveredAmount,
totalImmediateAmount = expectedImmediateAmount
)
)
if (deliveredAmount >= bolusAmount) {
if (deliveredAmount >= expectedImmediateAmount) {
bolusDeliveryProgressReporter.setCurrentProgressStage(BasicProgressStage.Finished)
break
}
}
}
bolusFinishedCompletely = true
} catch (e: BolusDeliveryException) {
@ -1605,19 +1725,21 @@ class Pump(
if (historyDelta.isEmpty()) {
if (bolusFinishedCompletely) {
logger(LogLevel.ERROR) { "Bolus delivery did not actually occur" }
throw BolusNotDeliveredException(bolusAmount)
throw BolusNotDeliveredException(totalBolusAmount)
}
} else {
var numStandardBolusInfusedEntries = 0
var numRelevantBolusEntries = 0
var unexpectedBolusEntriesDetected = false
scanHistoryDeltaForBolusToEmit(
historyDelta,
reasonForLastStandardBolusInfusion = bolusReason
reasonForLastStandardBolusInfusion = standardBolusReason
) { entry ->
when (bolusType) {
CMDDeliverBolusType.STANDARD_BOLUS ->
when (val detail = entry.detail) {
is CMDHistoryEventDetail.StandardBolusInfused -> {
numStandardBolusInfusedEntries++
if (numStandardBolusInfusedEntries > 1)
numRelevantBolusEntries++
if (numRelevantBolusEntries > 1)
unexpectedBolusEntriesDetected = true
}
@ -1630,12 +1752,41 @@ class Pump(
unexpectedBolusEntriesDetected = true
}
}
CMDDeliverBolusType.EXTENDED_BOLUS ->
when (val detail = entry.detail) {
is CMDHistoryEventDetail.ExtendedBolusStarted -> {
numRelevantBolusEntries++
if (numRelevantBolusEntries > 1)
unexpectedBolusEntriesDetected = true
}
else -> {
if (detail.isBolusDetail)
unexpectedBolusEntriesDetected = true
}
}
CMDDeliverBolusType.MULTIWAVE_BOLUS ->
when (val detail = entry.detail) {
is CMDHistoryEventDetail.MultiwaveBolusStarted -> {
numRelevantBolusEntries++
if (numRelevantBolusEntries > 1)
unexpectedBolusEntriesDetected = true
}
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)
if (numRelevantBolusEntries == 0) {
logger(LogLevel.ERROR) { "History delta did not contain an entry about bolus delivery" }
throw BolusNotDeliveredException(totalBolusAmount)
} else if (unexpectedBolusEntriesDetected) {
logger(LogLevel.ERROR) { "History delta contained unexpected additional bolus entries" }
throw UnaccountedBolusDetectedException()
@ -1747,7 +1898,10 @@ class Pump(
// 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
val firstTDDScreen = navigateToRTScreen(
rtNavigationContext,
ParsedScreen.MyDataDailyTotalsScreen::class,
pumpSuspended) as ParsedScreen.MyDataDailyTotalsScreen
processTDDScreen(firstTDDScreen)
longPressRTButtonUntil(rtNavigationContext, RTNavigationButton.DOWN) { parsedScreen ->
@ -1788,8 +1942,6 @@ class Pump(
* [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,
@ -2523,7 +2675,8 @@ class Pump(
val expectedCurrentTbrPercentage = currentTbrState.tbr.percentage
val actualCurrentTbrPercentage = status.tbrPercentage
val elapsedTimeSinceTbrStart = now - currentTbrState.tbr.timestamp
val expectedRemainingDurationInMinutes = currentTbrState.tbr.durationInMinutes - elapsedTimeSinceTbrStart.inWholeMinutes.toInt()
val expectedRemainingDurationInMinutes =
currentTbrState.tbr.durationInMinutes - elapsedTimeSinceTbrStart.inWholeMinutes.toInt()
val actualRemainingDurationInMinutes = status.remainingTbrDurationInMinutes
// The remaining duration check uses a tolerance range of 10 minutes, since
@ -2765,7 +2918,8 @@ class Pump(
numRetrievedFactors++
logger(LogLevel.DEBUG) {
"Got basal profile factor #$factorIndexOnScreen : $factor; $numRetrievedFactors factor(s) read and $numObservedScreens screen(s) observed thus far"
"Got basal profile factor #$factorIndexOnScreen : $factor; $numRetrievedFactors " +
"factor(s) read and $numObservedScreens screen(s) observed thus far"
}
getBasalProfileReporter.setCurrentProgressStage(
@ -3192,6 +3346,23 @@ class Pump(
}
}
private suspend fun lookupActiveTbrDetails(): Pair<Int, Int> {
// Go to the TBR percentage screen, which shows both percentage and remaining duration.
val tbrPercentageScreen = navigateToRTScreen(
rtNavigationContext,
ParsedScreen.TemporaryBasalRatePercentageScreen::class,
pumpSuspended
) as? ParsedScreen.TemporaryBasalRatePercentageScreen ?: throw NoUsableRTScreenException()
// The getParsedDisplayFrame() calls inside navigateToRTScreen()
// should already filter out blinked-out screens.
check(!tbrPercentageScreen.isBlinkedOut)
// Now get back to the main menu to return back to the initial state.
navigateToRTScreen(rtNavigationContext, ParsedScreen.MainScreen::class, pumpSuspended)
return Pair(tbrPercentageScreen.percentage ?: 100, tbrPercentageScreen.remainingDurationInMinutes ?: 0)
}
private suspend fun updateStatusByReadingMainAndQuickinfoScreens(switchStatesIfNecessary: Boolean) {
val mainScreen = navigateToRTScreen(rtNavigationContext, ParsedScreen.MainScreen::class, pumpSuspended)
@ -3265,8 +3436,27 @@ class Pump(
)
}
is MainScreenContent.ExtendedOrMultiwaveBolus ->
throw ExtendedOrMultiwaveBolusActiveException(mainScreenContent)
is MainScreenContent.ExtendedOrMultiwaveBolus -> {
val (tbrPercentage, remainingTbrDurationInMinutes) = if (mainScreenContent.tbrIsActive) {
lookupActiveTbrDetails()
} else {
Pair(100, 0)
}
Status(
availableUnitsInReservoir = quickinfo.availableUnits,
activeBasalProfileNumber = mainScreenContent.activeBasalProfileNumber,
currentBasalRateFactor = if (tbrPercentage != 0)
mainScreenContent.currentBasalRateFactor * 100 / tbrPercentage
else
0,
tbrOngoing = (tbrPercentage != 100),
remainingTbrDurationInMinutes = remainingTbrDurationInMinutes,
tbrPercentage = tbrPercentage,
reservoirState = quickinfo.reservoirState,
batteryState = mainScreenContent.batteryState
)
}
}
if (switchStatesIfNecessary) {

View file

@ -449,8 +449,7 @@ suspend fun longPressRTButtonUntil(
// Record the screen we just saw so we can return it.
lastParsedScreen = parsedScreen
return@startLongButtonPress false
}
else
} else
return@startLongButtonPress true
}
@ -1005,7 +1004,9 @@ suspend fun navigateToRTScreen(
// 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)) {
if ((parsedScreen::class != ParsedScreen.UnrecognizedScreen::class) &&
(previousScreenType != null) &&
(previousScreenType == parsedScreen::class)) {
logger(LogLevel.DEBUG) { "Got a screen of the same type ${parsedScreen::class}; skipping" }
continue
}

View file

@ -4,10 +4,10 @@ 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
import kotlin.reflect.KClassifier
/*****************************************
*** Screen and screen content classes ***
@ -1416,7 +1416,7 @@ class ExtendedAndMultiwaveBolusMainScreenParser : Parser() {
}
val batteryState = batteryStateFromSymbol(
if (parseResult.size >= 7) parseResult.valueAt<Glyph.SmallSymbol>(5).symbol else null
if (parseResult.size >= 7) parseResult.valueAt<Glyph.SmallSymbol>(6).symbol else null
)
val remainingBolusDuration = parseResult.valueAt<LocalDateTime>(0)

View file

@ -3,11 +3,11 @@ 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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
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 {

View file

@ -7,12 +7,12 @@ 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
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PumpIOTest {
// Common test code.
@ -599,23 +599,23 @@ class PumpIOTest {
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 0, second = 19),
80666,
ApplicationLayer.CMDHistoryEventDetail.ExtendedBolusStarted(177, 15)
ApplicationLayer.CMDHistoryEventDetail.ExtendedBolusStarted(177, 15, true)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 15, second = 18),
80668,
ApplicationLayer.CMDHistoryEventDetail.ExtendedBolusEnded(177, 15)
ApplicationLayer.CMDHistoryEventDetail.ExtendedBolusEnded(177, 15, true)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 21, second = 31),
80670,
ApplicationLayer.CMDHistoryEventDetail.MultiwaveBolusStarted(193, 37, 30)
ApplicationLayer.CMDHistoryEventDetail.MultiwaveBolusStarted(193, 37, 30, true)
),
ApplicationLayer.CMDHistoryEvent(
LocalDateTime(year = 2021, monthNumber = 2, dayOfMonth = 9, hour = 17, minute = 51, second = 8),
80672,
ApplicationLayer.CMDHistoryEventDetail.MultiwaveBolusEnded(193, 37, 30)
ApplicationLayer.CMDHistoryEventDetail.MultiwaveBolusEnded(193, 37, 30, true)
),
ApplicationLayer.CMDHistoryEvent(

View file

@ -5,6 +5,8 @@ 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 kotlinx.coroutines.Job
import kotlinx.datetime.UtcOffset
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@ -13,8 +15,6 @@ 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

View file

@ -4,7 +4,6 @@ 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

View file

@ -6,8 +6,8 @@ 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
import kotlin.test.assertNotNull
class TestComboIO : ComboIO {
val sentPacketData = newTestPacketSequence()

View file

@ -4,14 +4,14 @@ 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
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.test.fail
// Utility function to combine runBlocking() with a watchdog.
// A coroutine is started with runBlocking(), and inside that

View file

@ -20,17 +20,17 @@ 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 kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.BeforeAll
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 {

View file

@ -13,13 +13,6 @@ 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
@ -29,7 +22,14 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime
import org.junit.jupiter.api.BeforeAll
import kotlin.reflect.KClassifier
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class RTNavigationTest {
/* RTNavigationContext implementation for testing out RTNavigation functionality.
@ -360,7 +360,6 @@ class RTNavigationTest {
decrementButton = RTNavigationButton.DOWN
)
assertEquals(Pair(0, RTNavigationButton.CHECK), result)
}
@Test

View file

@ -2,12 +2,12 @@ package info.nightscout.comboctl.parser
import info.nightscout.comboctl.base.DisplayFrame
import info.nightscout.comboctl.base.timeWithoutDate
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
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) {

View file

@ -81,7 +81,7 @@ class ComboV2Fragment : DaggerFragment() {
else
rh.gs(R.string.combov2_cancelling_tbr)
is ComboCtlPump.DeliveringBolusCommandDesc ->
rh.gs(R.string.combov2_delivering_bolus_cmddesc, desc.bolusAmount.cctlBolusToIU())
rh.gs(R.string.combov2_delivering_bolus_cmddesc, desc.immediateBolusAmount.cctlBolusToIU())
is ComboCtlPump.FetchingTDDHistoryCommandDesc ->
rh.gs(R.string.combov2_fetching_tdd_history_cmddesc)
is ComboCtlPump.UpdatingPumpDateTimeCommandDesc ->

View file

@ -1768,8 +1768,8 @@ class ComboV2Plugin @Inject constructor (
is RTCommandProgressStage.DeliveringBolus ->
rh.gs(
R.string.combov2_delivering_bolus,
stage.deliveredAmount.cctlBolusToIU(),
stage.totalAmount.cctlBolusToIU()
stage.deliveredImmediateAmount .cctlBolusToIU(),
stage.totalImmediateAmount.cctlBolusToIU()
)
else -> ""
}

View file

@ -35,6 +35,27 @@ class ComboV2PairingActivity : DaggerAppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: Combov2PairingActivityBinding = DataBindingUtil.setContentView(
this, R.layout.combov2_pairing_activity)
// In the NotInitialized state, the PumpManager is unavailable because it cannot
// function without Bluetooth permissions. Several of ComboV2Plugin's functions
// such as getPairingProgressFlow() depend on PumpManager though. To prevent UI
// controls from becoming active without having a PumpManager, show instead a
// view on the activity that explains why pairing is currently not possible.
if (combov2Plugin.driverStateUIFlow.value == ComboV2Plugin.DriverState.NotInitialized) {
aapsLogger.info(LTag.PUMP, "Cannot pair right now; disabling pairing UI controls, showing message instead")
binding.combov2PairingSectionInitial.visibility = View.GONE
binding.combov2PairingSectionCannotPairDriverNotInitialized.visibility = View.VISIBLE
binding.combov2CannotPairGoBack.setOnClickListener {
finish()
}
return
}
// 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
@ -54,9 +75,6 @@ class ComboV2PairingActivity : DaggerAppCompatActivity() {
startPairingActivityLauncher.launch(intent)
}
val binding: Combov2PairingActivityBinding = DataBindingUtil.setContentView(
this, R.layout.combov2_pairing_activity)
binding.combov2PairingFinishedOk.setOnClickListener {
finish()
}
@ -263,6 +281,10 @@ class ComboV2PairingActivity : DaggerAppCompatActivity() {
}
override fun onDestroy() {
// In the NotInitialized state, getPairingProgressFlow() crashes because there
// is no PumpManager present. But in that state, the pairing progress flow needs
// no reset because no pairing can happen in that state anyway.
if (combov2Plugin.driverStateUIFlow.value != ComboV2Plugin.DriverState.NotInitialized) {
// 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
@ -276,8 +298,10 @@ class ComboV2PairingActivity : DaggerAppCompatActivity() {
)
combov2Plugin.resetPairingProgress()
}
else -> Unit
}
}
super.onDestroy()
}

View file

@ -41,6 +41,43 @@
</androidx.appcompat.widget.LinearLayoutCompat>
<ScrollView
android:id="@+id/combov2_pairing_section_cannot_pair_driver_not_initialized"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:singleLine="false"
android:text="@string/combov2_cannot_pair_driver_not_initialized_explanation"
android:textSize="16sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="horizontal">
<Button
android:id="@+id/combov2_cannot_pair_go_back"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/combov2_go_back" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<ScrollView
android:id="@+id/combov2_pairing_section_initial"
android:layout_width="match_parent"

View file

@ -137,4 +137,6 @@ buttons at the same time to cancel pairing)\n
<string name="combov2_dst_ended">Daylight savings time (DST) ended</string>
<string name="combov2_cannot_connect_pump_error_observed">Cannot connect to pump because the pump reported an error. User must handle the error and then either wait 5 minutes or press the Refresh button in the driver tab.</string>
<string name="combov2_refresh_pump_status_after_error">Refreshing pump status after the pump reported an error</string>
<string name="combov2_go_back">Go back</string>
<string name="combov2_cannot_pair_driver_not_initialized_explanation">Cannot perform pairing because the driver is not initialized. This typically happens because the necessary Bluetooth permissions have not been granted. Go back, grant the Bluetooth permissions, then try again to pair.</string>
</resources>