diff --git a/pump/combov2/README.md b/pump/combov2/README.md new file mode 100644 index 0000000000..17727f3561 --- /dev/null +++ b/pump/combov2/README.md @@ -0,0 +1,153 @@ +# Overview over combov2's and ComboCtl's architecture + +ComboCtl is the core driver. It uses Kotlin Multiplatform and is written in a platform agnostic +way. The code is located in `comboctl/`, and is also available in [its own separate repository] +(https://github.com/dv1/ComboCtl). That separate repository is kept in sync with the ComboCtl +copy in AndroidAPS as much as possible, with some notable changes (see below). "combov2" is the +name of the AndroidAPS driver. In short: combov2 = ComboCtl + extra AndroidAPS integration code. + +## Directory structure + +The directory structure of the local ComboCtl itself is: + +* `comboctl/src/commonMain/` : The platform agnostic portion of ComboCtl. The vast majority of + ComboCtl's logic is contained there. +* `comboctl/src/androidMain/` : The Android specific code. This in particular contains + implementations of the Bluetooth interfaces that are defined in `commonMain/`. +* `comboctl/src/jvmTest/` : Unit tests. This subdirectory is called `jvmTest` because in the + ComboCtl repository, there is also a `jvmMain/` subdirectory, and the unit tests are run + with the JVM. + +The AndroidAPS specific portion of the driver is located in `src/`. This connects ComboCtl with +AndroidAPS. In particular, this is where the `ComboV2Plugin` class is located. That's the main +entrypoint for the combov2 driver plugin. + +## Basic description of how ComboCtl communicates with the pump + +ComboCtl uses Kotlin coroutines. It uses [the Default dispatcher](https://kotlinlang. +org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-default.html), +with [a limitedParallelism](https://kotlinlang.org/api/kotlinx. +coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-dispatcher/limited-parallelism.html) +constraint to prevent actual parallelism, that is, to not let coroutine jobs run on multiple +threads concurrently. Coroutines are used in ComboCtl to greatly simplify the communication steps, +which normally require a number of state machines to be implemented manually. Stackless coroutines +like Kotlin's essentially are automatically generated state machines under the hood, and this is +what they are used for here. Enabling parallelism is not part of such a state machine. Furthermore, +communication with the Combo does not benefit from parallelism. + +The communication code in ComboCtl is split in higher level operations (in its `Pump` class) and +lower level ones (in its `PumpIO` class). `Pump` instantiates `PumpIO` internally, and focuses on +implementing functionality like reading basal profiles, setting TBRs etc. `PumpIO` implements the +building blocks for these higher level operations. In particular, `PumpIO` has an internal +coroutine scope that is used for sending data to the Combo and for running a "heartbeat" loop. +That "heartbeat" is a message that needs to be regularly sent to the Combo (unless other data is +sent to the Combo in time, like a command to press a button). If nothing is sent to a Combo for +some time, it will eventually disconnect. For this reason, that heartbeat loop is necessary. + +PumpIO also contains the code for performing the pump pairing. + +Going further down a level, `TransportLayer` implements the IO code to generate packets for the +Combo and parse packets coming from the Combo. This includes code for authenticating outgoing +packets and for checking incoming ones. `TransportLayer` also contains the `IO` subclass, which +actually transfers packets to and receives data from the Combo. + +One important detail to keep in mind about the `IO` class is that it enforces a packet send +interval of 200 ms. That is: The time between packet transmission is never shorter than 200 ms +(it is OK to be longer). The interval is important, because the Combo has a ring buffer for the +packet it receives, and transmitting packets to the Combo too quickly causes an overflow and a +subsequent error in the Combo, which then terminates the connection. + +The Combo can run in three modes. The first one is the "service" mode, which is only briefly +used for setting up the connection. Immediately after the connection is established, the pump +continues in the "command" or "remote terminal" (abbr. "RT") mode. The "command" mode is what the +remote control of the Combo uses for its direct commands (that is, delivering bolus and retrieving +the latest changes / activities from the history). The "remote terminal" mode replicates the LCD +on the pump itself along with the 4 Combo buttons. + +Only a few operations are possible in the command mode. In particular, the driver uses the bolus +delivery command from the command mode, the command to retrieve a history delta, and the command +for getting the pump's current date and time. But everything else (getting basal profile, setting +TBR, getting pump status...) is done in the remote terminal mode, by emulating a user pressing +buttons. This unfortunately means that these operations are performed slowly, but there is no +other choice. + +## Details about long-pressing RT buttons + +As part of operations like reading the pump's profile, an emulated long RT button press is sometimes +used. Such long presses cause more rapid changes compared to multiple short button presses. A +button press is "long" when the emulated user "holds down" the button, while a short button press +equals pressing and immediately releasing the emulated button. + +The greater speed of long button presses comes with a drawback though: "Overshoots" can happen. For +example, if long button pressing is used for adjusting a quantity on screen, then the quantity may +still get incremented/decremented after the emulated user "released" the button. It is therefore +necessary to check the quantity on screen, and finetune it with short button presses afterwards +if necessary. + +## Idempotent and non-idempotent high level commands + +A command is _idempotent_ if it can be repeated if the connection to the pump was lost. Most +commands are idempotent. For example, reading the basal profile can be repeated if during the +initial basal profile retrieval the connection was lost (for example because the user walked away +from the pump). After a few attempts to repeat the command, an error is produced (to avoid an +infinite loop). + +Currently, there is only one non-idempotent command: Delivering a bolus. This one _cannot_ be +repeated, otherwise there is a high risk of infusing too much insulin. Instead, in case of a +connection failure, the delivering bolus command fails immediately and is not automatically +attempted again. + +## Automatic datetime adjustments and timezone offset handling + +ComboCtl automatically adjusts the date and time of the Combo. This is done through the RT mode, +since there is no command-mode command to _set_ the current datetime (but there is one for +_getting_ the current datetime). But since the Combo cannot store a timezone offset (it only stores +localtime), the timezone offset that has been used so far is stored in a dedicated field in the +pump state store that ComboCtl uses. DST changes and timezone changes can be tracked properly with +this logic. + +The pump's current datetime is always retrieved (through the command mode) every time a connection +is established to it, and compared to the system's current datetime. If these two differ too much, +the pump's datetime is automatically adjusted. This keeps the pump's datetime in sync. + +## Notes about how TBRs are set + +TBRs are set through the remote terminal mode. The driver assumes that the Combo is configured +to use 15-minute TBR duration steps sizes and a TBR percentage maximum of 500%. There is code +in the driver to detect if the maximum is not set to 500%. If AndroidAPS tries to set a percentage +that is higher than the actually configured maximum, then eventually, an error is reported. + +:warning: The duration step size cannot be detected by the driver. The user _must_ make sure that +the step size is configured to 15 minutes. + +## Pairing with a Combo and the issue with pump queue connection timeouts + +When pairing, the pump queue's internal timeout is likely to be reached. Essentially, the queue +tries to connect to the pump right after the driver was selected in the configuration. But +a connection cannot be established because the pump is not yet paired. + +When the queue attempts to connect to the pump, it "thinks" that if the connect procedure does not +complete after 120 seconds, then the driver must be stuck somehow. The queue then hits a timeout. +The assumption about 120s is correct if the Combo is already paired (a connection should be set up +in far less time than 120s). But if it is currently being paired, the steps involved can take +about 2-3 minutes. + +For this reason, the driver automatically requests a pump update - which connects to the pump - +once pairing is done. + +## Changes to ComboCtl in the local copy + +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 with keeping the +`comboctl/` copy and the main project in sync since transferring changes then is straightforward. diff --git a/pump/combov2/comboctl-changes.txt b/pump/combov2/comboctl-changes.txt deleted file mode 100644 index 728e9ec14c..0000000000 --- a/pump/combov2/comboctl-changes.txt +++ /dev/null @@ -1,14 +0,0 @@ -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/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidBluetoothDevice.kt b/pump/combov2/comboctl/src/androidMain/kotlin/info/nightscout/comboctl/android/AndroidBluetoothDevice.kt index 8f954ec0cb..ae154d22e0 100644 --- 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 @@ -67,8 +67,9 @@ class AndroidBluetoothDevice( // 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. + // When all attempts fail, retryBlocking() lets the exception pass through. + // That exception is wrapped in BluetoothException, which then needs to be + // handled by the caller. val totalNumAttempts = 5 retryBlocking(numberOfRetries = totalNumAttempts, delayBetweenRetries = 100) { attemptNumber, previousException -> if (abortConnectAttempt) 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 index 3f2ee01dd0..e1dfee66dc 100644 --- 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 @@ -381,13 +381,6 @@ class AndroidBluetoothInterface(private val androidContext: Context) : Bluetooth // 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) { 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 index 745e23276c..68c7de77eb 100644 --- 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 @@ -2743,8 +2743,6 @@ class Pump( ) } - numObservedScreens++ - val factorIndexOnScreen = parsedScreen.beginTime.hour // numUnits null means the basal profile factor @@ -2752,6 +2750,11 @@ class Pump( if (parsedScreen.numUnits == null) return@longPressRTButtonUntil LongPressRTButtonsCommand.ContinuePressingButton + // Increase this _after_ checking for a blinking screen + // to not accidentally count the blinking and non-blinking + // screens as two separate ones. + numObservedScreens++ + // If the factor in the profile is >= 0, // it means it was already read earlier. if (basalProfileFactors[factorIndexOnScreen] >= 0) @@ -2759,14 +2762,16 @@ class Pump( val factor = parsedScreen.numUnits basalProfileFactors[factorIndexOnScreen] = factor - logger(LogLevel.DEBUG) { "Got basal profile factor #$factorIndexOnScreen : $factor" } + + numRetrievedFactors++ + logger(LogLevel.DEBUG) { + "Got basal profile factor #$factorIndexOnScreen : $factor; $numRetrievedFactors factor(s) read and $numObservedScreens screen(s) observed thus far" + } getBasalProfileReporter.setCurrentProgressStage( RTCommandProgressStage.GettingBasalProfile(numRetrievedFactors) ) - numRetrievedFactors++ - return@longPressRTButtonUntil if (numObservedScreens >= NUM_COMBO_BASAL_PROFILE_FACTORS) LongPressRTButtonsCommand.ReleaseButton else @@ -2819,21 +2824,19 @@ class Pump( } } + numRetrievedFactors++ 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) + // All factors retrieved. Press BACK repeatedly until we are back at the main menu. + cycleToRTScreen( + rtNavigationContext, + RTNavigationButton.BACK, + ParsedScreen.MainScreen::class + ) getBasalProfileReporter.setCurrentProgressStage(BasicProgressStage.Finished)