diff --git a/.circleci/config.yml b/.circleci/config.yml index 371e685527e7..199eb125f44e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,6 +42,28 @@ jobs: emulator -avd citest -delay-adb -verbose -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim background: true + - run: + name: Wait for emulator boot + command: | + export ANDROID_SDK_ROOT=/usr/lib/android-sdk + export ANDROID_HOME=/usr/lib/android-sdk + # Boot gate: the emulator above is launched in the background, so without this the test task + # can start before the device is online and fail with the misleading + # "com.android.builder.testing.api.DeviceException: No online devices found". + # Block until adb sees the device AND the system has fully booted, with a hard timeout so a + # stuck emulator fails loudly HERE (clear message) instead of later. + adb wait-for-device + timeout 420 bash -c 'until [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d "\r")" = "1" ]; do sleep 2; done' + adb shell getprop sys.boot_completed + # Settle, dismiss keyguard and disable animations so ComposeMainActivity launches cleanly: + # a heavy UI launch on an unsettled emulator can ANR system_server and drop the device offline + # mid-run (the failure mode this hardening targets). + adb shell input keyevent 82 || true + adb shell settings put global window_animation_scale 0 || true + adb shell settings put global transition_animation_scale 0 || true + adb shell settings put global animator_duration_scale 0 || true + adb devices + - run: name: Run connectedFullDebugAndroidTest command: | diff --git a/.github/workflows/aaps-ci.yml b/.github/workflows/aaps-ci.yml index bc8071a315ad..9553f52cc785 100644 --- a/.github/workflows/aaps-ci.yml +++ b/.github/workflows/aaps-ci.yml @@ -6,10 +6,12 @@ on: tagName: description: 'Select AAPS Version' required: true - default: '3.4.2.3' + default: '3.4.2.6' type: choice options: # ── 3.4.x (JDK 21) ────────────────────────────── + - 3.4.2.6 + - 3.4.2.4 - 3.4.2.3 - 3.4.2.2 - 3.4.2.1 @@ -354,3 +356,17 @@ jobs: upload_to_gdrive "aaps-wear-${VERSION}${VERSION_SUFFIX}.apk" "aaps-wear-${VERSION}${VERSION_SUFFIX}.apk" echo "🎉 APKs successfully uploaded to Google Drive!" + + # Configuration: Threshold-based cleanup + # Clean up only occurs when total runs exceed: keep_runs + keep_threshold (e.g., 20 + 5 = 25) + cleanup: + name: Cleanup Old Workflow Runs + needs: build + if: always() + permissions: + actions: write + uses: ./.github/workflows/cleanup-workflow-runs.yml + with: + workflow_name: 'AAPS CI' + keep_runs: 20 + keep_threshold: 5 \ No newline at end of file diff --git a/.github/workflows/branch-ci.yml b/.github/workflows/branch-ci.yml index 8e731fbbe8c3..1e63e59e65f0 100644 --- a/.github/workflows/branch-ci.yml +++ b/.github/workflows/branch-ci.yml @@ -302,6 +302,8 @@ jobs: name: Cleanup Old Workflow Runs needs: build if: always() + permissions: + actions: write uses: ./.github/workflows/cleanup-workflow-runs.yml with: workflow_name: 'Branch CI' diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index cb97c9f8f4b2..e977642029ac 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -5,7 +5,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget object Versions { // On change edit aaps-ci.yml - const val appVersion = "3.4.2.3" + const val appVersion = "3.4.2.6" const val versionCode = 1500 const val compileSdk = 36 diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt index 94c2ba0ce922..e2d8701bbf1a 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt @@ -34,7 +34,7 @@ class PumpDescription { var isPatchPump = false var maxResorvoirReading = 50 var useHardwareLink = false - + var extendedBolusMinAmount = 0.0 fun resetSettings() { isBolusCapable = true bolusStep = 0.1 @@ -64,6 +64,7 @@ class PumpDescription { needsManualTDDLoad = true hasCustomUnreachableAlertCheck = false useHardwareLink = false + extendedBolusMinAmount = extendedBolusStep } companion object { diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpDescriptionExtension.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpDescriptionExtension.kt index 9ef7c6eb8e2c..3344beb84091 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpDescriptionExtension.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpDescriptionExtension.kt @@ -41,6 +41,7 @@ fun PumpDescription.fillFor(pumpType: PumpType): PumpDescription { isPatchPump = pumpType.isPatchPump() maxResorvoirReading = pumpType.maxReservoirReading() useHardwareLink = pumpType.useHardwareLink + pumpType.extendedBolusSettings()?.minDose?.let { extendedBolusMinAmount = it } return this } \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclientV3/extensions/ProfileSwitchExtension.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclientV3/extensions/ProfileSwitchExtension.kt index 96dddb93756b..29bc867675fc 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclientV3/extensions/ProfileSwitchExtension.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclientV3/extensions/ProfileSwitchExtension.kt @@ -33,7 +33,7 @@ fun NSProfileSwitch.toProfileSwitch(activePlugin: ActivePlugin, dateUtil: DateUt profileName = originalProfileName ?: profile, timeshift = timeShift ?: 0, percentage = percentage ?: 100, - duration = duration ?: 0L, + duration = originalDuration ?: duration ?: 0L, iCfg = profileSealed.iCfg, ids = IDs(nightscoutId = identifier, pumpId = pumpId, pumpType = PumpType.fromString(pumpType), pumpSerial = pumpSerial, endId = endId) ) diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nsclientV3/extensions/ProfileSwitchExtensionKtTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nsclientV3/extensions/ProfileSwitchExtensionKtTest.kt index 7b79035bb34f..f8f84a5fb83c 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nsclientV3/extensions/ProfileSwitchExtensionKtTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nsclientV3/extensions/ProfileSwitchExtensionKtTest.kt @@ -5,6 +5,7 @@ import app.aaps.core.data.model.IDs import app.aaps.core.data.model.PS import app.aaps.core.data.pump.defs.PumpType import app.aaps.core.interfaces.insulin.Insulin +import app.aaps.core.nssdk.localmodel.treatment.EventType import app.aaps.core.nssdk.localmodel.treatment.NSProfileSwitch import app.aaps.core.nssdk.mapper.convertToRemoteAndBack import app.aaps.plugins.sync.extensions.contentEqualsTo @@ -83,4 +84,45 @@ internal class ProfileSwitchExtensionKtTest : TestBaseWithProfile() { assertThat(profileSwitch.contentEqualsTo(profileSwitch2)).isTrue() assertThat(profileSwitch.ids.contentEqualsTo(profileSwitch2.ids)).isTrue() } + + private fun nsProfileSwitch(duration: Long?, originalDuration: Long?) = NSProfileSwitch( + date = 10000, + identifier = "nightscoutId", + utcOffset = 0, + isValid = true, + eventType = EventType.PROFILE_SWITCH, + pumpId = 11000, + endId = null, + pumpType = PumpType.DANA_I.name, + pumpSerial = "bbbb", + profileJson = validProfile.toPureNsJson(dateUtil), + profile = "SomeProfile", + originalProfileName = "SomeProfile", + timeShift = 0, + percentage = 100, + duration = duration, + originalDuration = originalDuration + ) + + /** + * Documents uploaded before 9b90eea774 carry a duration inflated by 60000 and no + * durationInMilliseconds, so TreatmentMapper inflates it by 60000 a second time. + * originalDuration is written in milliseconds by every AAPS version and must win. + */ + @Test + fun inflatedLegacyDurationIsIgnoredInFavourOfOriginalDuration() { + val nineHours = 9 * 3600000L + val parsed = nsProfileSwitch(duration = nineHours * 60000 * 60000, originalDuration = nineHours) + .toProfileSwitch(activePlugin, dateUtil)!! + assertThat(parsed.duration).isEqualTo(nineHours) + } + + /** A writer that supplies no originalDuration must still fall back to the mapped duration. */ + @Test + fun durationIsUsedWhenOriginalDurationIsMissing() { + val nineHours = 9 * 3600000L + val parsed = nsProfileSwitch(duration = nineHours, originalDuration = null) + .toProfileSwitch(activePlugin, dateUtil)!! + assertThat(parsed.duration).isEqualTo(nineHours) + } } diff --git a/pump/equil/src/main/kotlin/app/aaps/pump/equil/EquilPumpPlugin.kt b/pump/equil/src/main/kotlin/app/aaps/pump/equil/EquilPumpPlugin.kt index 0cd468ce2a0f..854aa23d8e94 100644 --- a/pump/equil/src/main/kotlin/app/aaps/pump/equil/EquilPumpPlugin.kt +++ b/pump/equil/src/main/kotlin/app/aaps/pump/equil/EquilPumpPlugin.kt @@ -61,6 +61,7 @@ import app.aaps.pump.equil.manager.command.PumpEvent import app.aaps.pump.equil.manager.customCommands.CmdModeAndHistoryGet import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign +import kotlin.math.max import org.joda.time.DateTime import org.joda.time.Duration import javax.inject.Inject @@ -115,20 +116,19 @@ class EquilPumpPlugin @Inject constructor( .toObservable(EventEquilAlarm::class.java) .observeOn(aapsSchedulers.io) .subscribe({ eventEquilError -> - commandQueue.performing()?.let { - if (it.commandType == Command.CommandType.BOLUS) { - aapsLogger.info( - LTag.PUMPCOMM, - "eventEquilError.tips====${eventEquilError.tips}" - ) - rxBus.send(EventDismissNotification(Notification.EQUIL_ALARM)) - equilManager.showNotification( - Notification.EQUIL_ALARM, - eventEquilError.tips, - Notification.URGENT, app.aaps.core.ui.R.raw.alarm - ) - stopBolusDelivering() - } + aapsLogger.info(LTag.PUMPCOMM, "eventEquilError.tips====${eventEquilError.tips}") + // Always surface the pump alarm - it is no longer gated on a bolus being in + // progress (alarms now come from the GATT history read on every connection, not + // just from an advertisement scan caught mid-bolus). See #5040. + rxBus.send(EventDismissNotification(Notification.EQUIL_ALARM)) + equilManager.showNotification( + Notification.EQUIL_ALARM, + eventEquilError.tips, + Notification.URGENT, app.aaps.core.ui.R.raw.alarm + ) + // But only halt bolus tracking if a bolus is actually delivering. + if (commandQueue.performing()?.commandType == Command.CommandType.BOLUS) { + stopBolusDelivering() } }, fabricPrivacy::logException) @@ -146,20 +146,30 @@ class EquilPumpPlugin @Inject constructor( else ToastUtils.infoToast(context, rh.gs(R.string.equil_error)) } }) - } else if (event.isChanged(DoubleKey.SafetyMaxBolus.key)) { - val profile = pumpSync.expectedPumpState().profile ?: return@subscribe - commandQueue.customCommand( - CmdSettingSet(constraintsChecker.getMaxBolusAllowed().value(), constraintsChecker.getMaxBasalAllowed(profile).value(), aapsLogger, preferences, equilManager), - object : Callback() { - override fun run() { - if (result.success) ToastUtils.infoToast(context, rh.gs(R.string.equil_pump_updated)) - else ToastUtils.infoToast(context, rh.gs(R.string.equil_error)) - } - }) + } else if (event.isChanged(DoubleKey.SafetyMaxBolus.key) || event.isChanged(DoubleKey.ApsMaxBasal.key)) { + resendPumpSettings() } }, fabricPrivacy::logException) } + // Re-program the pod's bolus/basal thresholds. The pod enforces the basal threshold (see CmdSettingSet) + // as a hard limit; if it goes stale-low, temp basals in the gap are silently rejected (the pump drops + // BLE with status 19). Use the STABLE max(ApsMaxBasal, maxDailyBasal) for basal — the same ceiling + // OpenAPS caps temp basals to — not the time-of-day-dependent getMaxBasalAllowed. Kept in sync on + // max-bolus / max-basal pref changes and on profile set (maxDailyBasal is profile-dependent). + private fun resendPumpSettings() { + val profile = pumpSync.expectedPumpState().profile ?: return + val maxBasal = max(preferences.get(DoubleKey.ApsMaxBasal), profile.getMaxDailyBasal()) + commandQueue.customCommand( + CmdSettingSet(constraintsChecker.getMaxBolusAllowed().value(), maxBasal, aapsLogger, preferences, equilManager), + object : Callback() { + override fun run() { + if (result.success) ToastUtils.infoToast(context, rh.gs(R.string.equil_pump_updated)) + else ToastUtils.infoToast(context, rh.gs(R.string.equil_error)) + } + }) + } + var tempActivationProgress = ActivationProgress.NONE var indexEquilReadStatus = 5 @@ -210,6 +220,17 @@ class EquilPumpPlugin @Inject constructor( val mode = equilManager.equilState?.runMode if (mode === RunMode.RUN || mode === RunMode.SUSPEND) { val basalSchedule = BasalSchedule.mapProfileToBasalSchedule(profile) + // Raise the pod's max-basal threshold BEFORE programming the schedule, so the pod accepts both + // the base schedule and later temp basals. Use the STABLE max(ApsMaxBasal, maxDailyBasal) — the + // same ceiling OpenAPS caps temp basals to — NOT getMaxBasalAllowed, which is time-of-day + // dependent (4x current basal) and can dip below the profile's own peak, wrongly starving the + // threshold. Sent directly (we're already inside a queued command — don't re-queue), gated on + // success (don't program the schedule on a stale threshold), and paced by EQUIL_BLE_NEXT_CMD + // like every other chained-command site so the second BLE write isn't dropped. + val maxBasal = max(preferences.get(DoubleKey.ApsMaxBasal), profile.getMaxDailyBasal()) + val settingResult = equilManager.executeCmd(CmdSettingSet(constraintsChecker.getMaxBolusAllowed().value(), maxBasal, aapsLogger, preferences, equilManager)) + if (!settingResult.success) return settingResult + SystemClock.sleep(EquilConst.EQUIL_BLE_NEXT_CMD) val pumpEnactResult = equilManager.executeCmd(CmdBasalSet(basalSchedule, profile, aapsLogger, preferences, equilManager)) if (pumpEnactResult.success) equilManager.equilState?.basalSchedule = basalSchedule return pumpEnactResult diff --git a/pump/equil/src/main/kotlin/app/aaps/pump/equil/ble/EquilBLE.kt b/pump/equil/src/main/kotlin/app/aaps/pump/equil/ble/EquilBLE.kt index adbdb6779cdc..b163f53870c7 100644 --- a/pump/equil/src/main/kotlin/app/aaps/pump/equil/ble/EquilBLE.kt +++ b/pump/equil/src/main/kotlin/app/aaps/pump/equil/ble/EquilBLE.kt @@ -18,7 +18,6 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Handler import android.os.HandlerThread -import android.os.Looper import android.os.Message import android.os.SystemClock import android.text.TextUtils @@ -39,9 +38,7 @@ import app.aaps.pump.equil.manager.Utils import app.aaps.pump.equil.manager.command.BaseCmd import app.aaps.pump.equil.manager.command.CmdDevicesOldGet import app.aaps.pump.equil.manager.command.CmdHistoryGet -import app.aaps.pump.equil.manager.command.CmdInsulinGet import app.aaps.pump.equil.manager.command.CmdPair -import app.aaps.pump.equil.manager.command.CmdRunningModeGet import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -123,6 +120,14 @@ class EquilBLE @Inject constructor( isConnected = true equilManager.equilState?.bluetoothConnectionState = BluetoothConnectionState.CONNECTED handler.removeMessages(TIME_OUT_CONNECT_WHAT) + // Link is up: stop the parallel advert-harvest scan (the pump stops advertising once + // connected anyway). If it already caught an advert it stopped itself in onScanResult. + stopScan() + synchronized(notifyLock) { + // New link: notifications not yet enabled. Block command dispatch until onDescriptorWrite. + notificationEnabled = false + dispatchedCmd = null + } bluetoothGatt?.discoverServices() updateCmdStatus(ResolvedResult.FAILURE) // rxBus.send(new EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTED)); @@ -175,7 +180,12 @@ class EquilBLE @Inject constructor( aapsLogger.debug(LTag.PUMPBTCOMM, "onDescriptorWrite received: $status") if (status == BluetoothGatt.GATT_SUCCESS) { aapsLogger.debug(LTag.PUMPBTCOMM, "onDescriptorWrite: Wrote GATT Descriptor successfully.") - ready() + synchronized(notifyLock) { + notificationEnabled = true + // Notifications live: send the command (queue-opened, deferred, or issued while the + // link was down). Send-once via dispatchedCmd so it can't collide/double with writeCmd. + dispatchCmd() + } } } } @@ -225,9 +235,11 @@ class EquilBLE @Inject constructor( } fun disconnect() { + stopScan() // stop any in-flight advert-harvest scan (hybrid connect) isConnected = false connecting = false startTrue = false + connectInitiated = false autoScan = false equilManager?.equilState?.bluetoothConnectionState = BluetoothConnectionState.DISCONNECTED aapsLogger.debug(LTag.PUMPBTCOMM, "Closing GATT connection") @@ -236,51 +248,104 @@ class EquilBLE @Inject constructor( bluetoothGatt = null baseCmd = null preCmd = null + synchronized(notifyLock) { + notificationEnabled = false + dispatchedCmd = null + } rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.DISCONNECTED)) } fun closeBleAuto() { - handler.postDelayed({ - disconnect() - }, EquilConst.EQUIL_BLE_NEXT_CMD) + // Tear down immediately. The AAPS command queue owns the connection lifecycle: after the last + // command it holds the link for waitForDisconnectionInSeconds() (5 s) for reuse and only then + // calls Pump.disconnect() -> here. No extra driver-side linger is needed, and an immediate + // teardown avoids the mid-command race that a deferred, cancellable timer would introduce. + disconnect() } var autoScan = true private fun findEquil(mac: String) { if (mac.isEmpty()) return if (isConnected) return - val equilDevice: BluetoothDevice? = bluetoothAdapter?.getRemoteDevice(mac) - if (autoScan) startScan() - else connectEquil(equilDevice) + // Known pump: connect straight to the MAC (autoConnect), no scan. See connect() / #5040. + // Mirror connect()'s state handling so isConnecting() reflects the in-flight direct connect. + connecting = true + equilManager?.equilState?.bluetoothConnectionState = BluetoothConnectionState.CONNECTING + connectEquil(bluetoothAdapter?.getRemoteDevice(mac)) } fun connectEquil(device: BluetoothDevice?) { handler.postDelayed({ if (device != null) { aapsLogger.debug(LTag.PUMPCOMM, "connectEquil======") - bluetoothGatt = device.connectGatt(context, false, mGattCallback, BluetoothDevice.TRANSPORT_LE) + // autoConnect = true: the Android stack completes the link as soon as the (known/bonded) + // pump is in range, with no app-level scan. This replaces flaky scan discovery, which took + // 60-90 s on many phones and caused command timeouts / "no insulin delivered" (#5040). + bluetoothGatt = device.connectGatt(context, true, mGattCallback, BluetoothDevice.TRANSPORT_LE) } }, 500) } private var baseCmd: BaseCmd? = null private var preCmd: BaseCmd? = null + + // Notification-readiness gate for the current GATT connection. Android allows only ONE outstanding + // GATT operation at a time. When the queue's connect() phase opens the link, `isConnected` flips + // true at onConnectionStateChange(CONNECTED) - BEFORE onServicesDiscovered runs openNotification() + // (the notify-descriptor write). If a command's writeCmd then writes its first characteristic packet + // in that window, it collides with the pending descriptor write: writeDescriptor() returns false + // (log: "openNotification: false"), notifications never enable, the pump's replies never arrive, and + // the command idle-times-out after ~9 s -> "Pump connection failure / manually check delivered + // insulin" (bolus, tempBasal, and profile/CmdSettingSet all hit this via different writeCmd branches). + // Fix: never send on a connected link until onDescriptorWrite confirms notifications are enabled; + // dispatchedCmd makes that send-once (per link) so writeCmd and onDescriptorWrite can't double-send, and + // the command dispatches after connect regardless of which writeCmd branch opened the link - including a + // command issued while disconnected (the reservoir-change case, where the pump drops BLE between steps). + // Ported from the dev branch. See #4910 / #5040. + private val notifyLock = Any() + @Volatile private var notificationEnabled = false + private var dispatchedCmd: BaseCmd? = null + + // Send the current command's first packet exactly once per link, only after notifications are enabled. + // Null-safe (no-op during the pure connect handshake). Caller MUST hold notifyLock. + private fun dispatchCmd() { + val cmd = baseCmd + if (cmd != null && cmd !== dispatchedCmd) { + dispatchedCmd = cmd + ready() + } + } + fun writeCmd(baseCmd: BaseCmd) { aapsLogger.debug(LTag.PUMPCOMM, "writeCmd {}", baseCmd) this.baseCmd = baseCmd val mac: String = when (baseCmd) { - is CmdPair -> baseCmd.address + is CmdPair -> baseCmd.address is CmdDevicesOldGet -> baseCmd.address - else -> equilManager?.equilState?.address ?: error("Unknown MAC address") + else -> equilManager?.equilState?.address ?: error("Unknown MAC address") + } + if (isConnected) { + synchronized(notifyLock) { + if (!notificationEnabled) { + // Fresh link, notifications not enabled yet: defer ALL send paths. onDescriptorWrite -> + // dispatchCmd() sends the command once notifications are up (no descriptor collision). + preCmd = baseCmd + return + } + } } - autoScan = baseCmd is CmdRunningModeGet || baseCmd is CmdInsulinGet if (isConnected && baseCmd.isPairStep()) { - ready() + synchronized(notifyLock) { dispatchCmd() } } else if (isConnected) { - preCmd?.let { preCmd -> - baseCmd.runCode = preCmd.runCode - baseCmd.runPwd = preCmd.runPwd + val prevCmd = preCmd + if (prevCmd != null) { + baseCmd.runCode = prevCmd.runCode + baseCmd.runPwd = prevCmd.runPwd nextCmd2() + } else { + // GATT link opened by the queue's connect() phase, notifications already up: send this + // command as the first one on the open link (else the pump idle-disconnects, status 19). + synchronized(notifyLock) { dispatchCmd() } } } else { findEquil(mac) @@ -298,7 +363,7 @@ class EquilBLE @Inject constructor( preCmd = baseCmd } else { aapsLogger.debug(LTag.PUMPCOMM, "readHistory error") - synchronized(baseCmd) { (baseCmd as Object).notifyAll() } + synchronized(baseCmd) { (baseCmd as Any).notifyAll() } } } @@ -331,6 +396,7 @@ class EquilBLE @Inject constructor( } private var dataList: List = ArrayList() + @Synchronized fun decode(buffer: ByteArray) { val str = Utils.bytesToHex(buffer) @@ -357,7 +423,7 @@ class EquilBLE @Inject constructor( override fun handleMessage(msg: Message) { super.handleMessage(msg) when (msg.what) { - TIME_OUT_WHAT -> stopScan() + TIME_OUT_WHAT -> stopScan() TIME_OUT_CONNECT_WHAT -> { stopScan() @@ -370,12 +436,18 @@ class EquilBLE @Inject constructor( } } private var startTrue = false + + // One-shot guard: set true when onScanResult fires the connect for the current scan session, re-armed at + // each startScan(). Prevents the rapid LOW_LATENCY result stream from opening multiple GATT clients. + private var connectInitiated = false + private fun startScan() { macAddress = equilManager?.equilState?.address aapsLogger.debug(LTag.PUMPBTCOMM, "startScan====$startTrue====$macAddress===") if (macAddress.isNullOrEmpty()) return if (startTrue) return startTrue = true + connectInitiated = false connecting = true equilManager?.equilState?.bluetoothConnectionState = BluetoothConnectionState.CONNECTING if (ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED) { @@ -401,9 +473,26 @@ class EquilBLE @Inject constructor( if (connecting || isConnected) { return } - autoScan = true baseCmd = null - startScan() + macAddress = equilManager?.equilState?.address + val device = macAddress?.takeIf { it.isNotEmpty() }?.let { bluetoothAdapter?.getRemoteDevice(it) } + if (device != null) { + // Known/bonded pump: connect straight to its MAC (see connectEquil, autoConnect=true) instead + // of scanning-to-connect. Scan-to-connect was the #5040 bottleneck (60-90 s on many phones). + connecting = true + equilManager?.equilState?.bluetoothConnectionState = BluetoothConnectionState.CONNECTING + connectEquil(device) + // Hybrid: run a best-effort advertisement harvest IN PARALLEL. It does NOT gate the connection + // (autoConnect above owns that), but the advert carries data the GATT path can't get: the pump's + // current history index (needed so loadEquilHistory reads new records), battery/reservoir, and the + // live alarm state. autoScan=false so onScanResult only decodes the advert - it does not open a + // second GATT client. The scan is stopped on CONNECTED / onScanResult / disconnect. See #5040. + autoScan = false + startScan() + } else { + autoScan = true + startScan() + } } private fun buildScanFilters(): List { @@ -419,6 +508,10 @@ class EquilBLE @Inject constructor( private fun buildScanSettings(): ScanSettings { val builder = ScanSettings.Builder() + // Command connects are latency-sensitive (a bolus/temp-basal is waiting on discovery). The default + // SCAN_MODE_LOW_POWER duty-cycles the radio and can take tens of seconds to surface a bonded pump on + // some phones, long enough for the command to time out. Use LOW_LATENCY so the pump is found in ~1 s. + builder.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) builder.setReportDelay(0) return builder.build() } @@ -426,20 +519,25 @@ class EquilBLE @Inject constructor( private var scanCallback: ScanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { super.onScanResult(callbackType, result) - val name: String? = result.device.name - if (name?.isNotEmpty() == true) { - try { + // The scan is filtered by MAC address (buildScanFilters), so every result IS the target pump. + // Do NOT gate on result.device.name: it is frequently null until the OS caches the device name, + // which silently drops valid matches and stalls discovery for tens of seconds. Guard with a + // one-shot flag so the rapid LOW_LATENCY result stream opens only a single GATT client. + if (connectInitiated) return + connectInitiated = true + try { + result.scanRecord?.bytes?.let { bytes -> bleHandler.post { - equilManager?.decodeData(result.scanRecord!!.bytes, autoScan) + equilManager?.decodeData(bytes, autoScan) } - stopScan() - if (autoScan) { - updateCmdStatus(ResolvedResult.CONNECT_ERROR) - connectEquil(result.device) - } - } catch (e: Exception) { - e.printStackTrace() } + stopScan() + if (autoScan) { + updateCmdStatus(ResolvedResult.CONNECT_ERROR) + connectEquil(result.device) + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPBTCOMM, "onScanResult error", e) } } } diff --git a/pump/equil/src/main/kotlin/app/aaps/pump/equil/manager/EquilManager.kt b/pump/equil/src/main/kotlin/app/aaps/pump/equil/manager/EquilManager.kt index a2c55a7e69be..d2e64f634e33 100644 --- a/pump/equil/src/main/kotlin/app/aaps/pump/equil/manager/EquilManager.kt +++ b/pump/equil/src/main/kotlin/app/aaps/pump/equil/manager/EquilManager.kt @@ -185,7 +185,7 @@ class EquilManager @Inject constructor( updateHistory(equilHistoryRecord, command.resolvedResult) loadEquilHistory() result.success = command.cmdSuccess - result.enacted(true) + result.enacted(command.cmdSuccess) } catch (ex: Exception) { ex.printStackTrace() result.success(false).enacted(false).comment(ex.message ?: "Exception") diff --git a/ui/src/main/kotlin/app/aaps/ui/dialogs/ExtendedBolusDialog.kt b/ui/src/main/kotlin/app/aaps/ui/dialogs/ExtendedBolusDialog.kt index b710b5355786..ed4057dda91d 100644 --- a/ui/src/main/kotlin/app/aaps/ui/dialogs/ExtendedBolusDialog.kt +++ b/ui/src/main/kotlin/app/aaps/ui/dialogs/ExtendedBolusDialog.kt @@ -30,6 +30,7 @@ import java.text.DecimalFormat import java.util.LinkedList import javax.inject.Inject import kotlin.math.abs +import kotlin.math.max class ExtendedBolusDialog : DialogFragmentWithDate() { @@ -70,9 +71,10 @@ class ExtendedBolusDialog : DialogFragmentWithDate() { val maxInsulin = constraintChecker.getMaxExtendedBolusAllowed().value() val extendedStep = pumpDescription.extendedBolusStep + val minInsulin = pumpDescription.extendedBolusMinAmount binding.insulin.setParams( savedInstanceState?.getDouble("insulin") - ?: extendedStep, extendedStep, maxInsulin, extendedStep, DecimalFormat("0.00"), false, binding.okcancel.ok + ?: minInsulin, minInsulin, maxInsulin, extendedStep, DecimalFormat("0.00"), false, binding.okcancel.ok ) val extendedDurationStep = pumpDescription.extendedBolusDurationStep