diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt b/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt
index ebe8ba84713..9e60e5850bf 100644
--- a/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt
+++ b/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt
@@ -48,6 +48,7 @@ enum class IntKey(
ProtectionTypeBolus("bolus_protection", 0, 0, 5),
SafetyMaxCarbs("treatmentssafety_maxcarbs", 48, 1, 200),
LoopOpenModeMinChange("loop_openmode_min_change", 30, 0, 50, defaultedBySM = true),
+ LoopMinBgRecalcInterval("loop_min_bg_recalc_interval", 0, 0, 30),
ApsMaxSmbFrequency("smbinterval", 3, 1, 10, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb),
ApsMaxMinutesOfBasalToLimitSmb("smbmaxminutes", 30, 15, 120, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb),
ApsUamMaxMinutesOfBasalToLimitSmb("uamsmbmaxminutes", 30, 15, 120, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb),
diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/di/LoopModule.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/di/LoopModule.kt
index 6cce0fc99bd..c92d672c435 100644
--- a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/di/LoopModule.kt
+++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/di/LoopModule.kt
@@ -2,6 +2,7 @@ package app.aaps.plugins.aps.di
import app.aaps.plugins.aps.loop.CarbSuggestionReceiver
import app.aaps.plugins.aps.loop.LoopFragment
+import app.aaps.plugins.aps.loop.LoopIntervalPreference
import dagger.Module
import dagger.android.ContributesAndroidInjector
@@ -10,4 +11,5 @@ import dagger.android.ContributesAndroidInjector
abstract class LoopModule {
@ContributesAndroidInjector abstract fun contributesLoopFragment(): LoopFragment
@ContributesAndroidInjector abstract fun contributesCarbSuggestionReceiver(): CarbSuggestionReceiver
+ @ContributesAndroidInjector abstract fun contributesLoopIntervalPreference(): LoopIntervalPreference
}
\ No newline at end of file
diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreference.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreference.kt
new file mode 100644
index 00000000000..944c2ccea82
--- /dev/null
+++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopIntervalPreference.kt
@@ -0,0 +1,97 @@
+package app.aaps.plugins.aps.loop
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.widget.TextView
+import androidx.preference.Preference
+import androidx.preference.PreferenceViewHolder
+import app.aaps.core.interfaces.db.PersistenceLayer
+import app.aaps.core.interfaces.resources.ResourceHelper
+import app.aaps.core.interfaces.utils.DateUtil
+import app.aaps.core.data.time.T
+import app.aaps.core.keys.IntKey
+import app.aaps.core.keys.interfaces.Preferences
+import app.aaps.plugins.aps.R
+import com.google.android.material.button.MaterialButtonToggleGroup
+import dagger.android.HasAndroidInjector
+import javax.inject.Inject
+
+class LoopIntervalPreference(
+ ctx: Context,
+ attrs: AttributeSet? = null
+) : Preference(ctx, attrs) {
+
+ @Inject lateinit var preferences: Preferences
+ @Inject lateinit var rh: ResourceHelper
+ @Inject lateinit var dateUtil: DateUtil
+ @Inject lateinit var persistenceLayer: PersistenceLayer
+
+ private val valueToButton = mapOf(
+ 0 to R.id.btn_auto,
+ 1 to R.id.btn_1,
+ 2 to R.id.btn_2,
+ 3 to R.id.btn_3,
+ 4 to R.id.btn_4,
+ 5 to R.id.btn_5
+ )
+ private val buttonToValue = valueToButton.entries.associate { it.value to it.key }
+
+ init {
+ (context.applicationContext as HasAndroidInjector).androidInjector().inject(this)
+ layoutResource = R.layout.preference_loop_interval
+ key = IntKey.LoopMinBgRecalcInterval.key
+ }
+
+ override fun onBindViewHolder(holder: PreferenceViewHolder) {
+ super.onBindViewHolder(holder)
+ holder.isDividerAllowedAbove = false
+ holder.isDividerAllowedBelow = false
+ // Disable click on the row background so taps reach the toggle buttons instead
+ holder.itemView.isClickable = false
+
+ val titleView = holder.findViewById(R.id.title) as? TextView
+ val dynamicSummary = holder.findViewById(R.id.dynamic_summary) as? TextView
+ val warningText = holder.findViewById(R.id.warning_text) as? TextView
+ val helpText = holder.findViewById(R.id.help_text) as? TextView
+ val toggleGroup = holder.findViewById(R.id.toggle_group) as? MaterialButtonToggleGroup
+
+ titleView?.text = rh.gs(R.string.loop_min_bg_recalc_interval_title)
+ helpText?.text = rh.gs(R.string.loop_min_bg_recalc_interval_summary)
+
+ val currentValue = preferences.get(IntKey.LoopMinBgRecalcInterval)
+ updateDynamicText(dynamicSummary, warningText, currentValue)
+
+ toggleGroup?.clearOnButtonCheckedListeners()
+ valueToButton[currentValue]?.let { toggleGroup?.check(it) }
+
+ toggleGroup?.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (!isChecked) return@addOnButtonCheckedListener
+ val minutes = buttonToValue[checkedId] ?: return@addOnButtonCheckedListener
+ preferences.put(IntKey.LoopMinBgRecalcInterval, minutes)
+ updateDynamicText(dynamicSummary, warningText, minutes)
+ }
+ }
+
+ private fun updateDynamicText(summaryView: TextView?, warningText: TextView?, minutes: Int) {
+ val now = dateUtil.now()
+ val dailyCount = persistenceLayer.getApsResults(now - T.hours(24).msecs(), now).size
+ val loopsPerDay = if (minutes == 0) dailyCount else 1440 / minutes
+
+ // 1st line: always white, describes selected mode
+ summaryView?.text = if (minutes == 0) rh.gs(R.string.loop_recalc_every_sensor_reading)
+ else rh.gs(R.string.loop_recalc_loops_per_day, loopsPerDay)
+
+ // 2nd line: red warning, shown if actual or projected high usage
+ if (dailyCount > 500) { // actual high usage
+ warningText?.text = rh.gs(R.string.loop_recalc_daily_count_warning, dailyCount)
+ warningText?.setTextColor(rh.gc(app.aaps.core.ui.R.color.warning))
+ } else if (minutes in 1..2) { // low data but high-rate selection
+ warningText?.text = rh.gs(R.string.loop_recalc_high_rate_warning)
+ warningText?.setTextColor(rh.gc(app.aaps.core.ui.R.color.warning))
+ } else {
+ warningText?.text = rh.gs(R.string.loop_recalc_daily_count, dailyCount)
+ warningText?.setTextColor(rh.gc(android.R.color.white))
+ }
+ }
+}
diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt
index 23188313d23..50f6e853323 100644
--- a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt
+++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt
@@ -1046,6 +1046,7 @@ class LoopPlugin @Inject constructor(
title = rh.gs(app.aaps.core.ui.R.string.loop)
initialExpandedChildrenCount = 0
addPreference(AdaptiveIntPreference(ctx = context, intKey = IntKey.LoopOpenModeMinChange, dialogMessage = R.string.loop_open_mode_min_change_summary, title = R.string.loop_open_mode_min_change))
+ addPreference(LoopIntervalPreference(context))
}
}
diff --git a/plugins/aps/src/main/res/layout/preference_loop_interval.xml b/plugins/aps/src/main/res/layout/preference_loop_interval.xml
new file mode 100644
index 00000000000..cc8cf0dfc44
--- /dev/null
+++ b/plugins/aps/src/main/res/layout/preference_loop_interval.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/aps/src/main/res/values/strings.xml b/plugins/aps/src/main/res/values/strings.xml
index 30e1e85f956..c6ebebabab0 100644
--- a/plugins/aps/src/main/res/values/strings.xml
+++ b/plugins/aps/src/main/res/values/strings.xml
@@ -128,6 +128,13 @@
SMB set by pump
Minimal request change [%]
Open Loop will popup new change request only if change is bigger than this value in %. Default value is 20%
+ Loop frequency limit, mins
+ Sets the minimum time between full loop iterations. AAPS was designed for 5-minute CGM intervals — sensors that report more frequently can cause hundreds of extra loop cycles per day, increasing battery usage. 3 minutes or higher is recommended for most users.
+ Actual usage: %1$d loops in the last 24h
+ Actual usage: %1$d loops in the last 24h. This may excessively drain battery. A higher frequency limit is recommended.
+ May noticeably reduce battery life.
+ Recalculates every time a sensor reading arrives
+ ~%1$d loops/day
Fallback to SMB. Not enough TDD data.
Fallback to profile sensitivity. Not enough data. Reason: %1$s
diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPlugin.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPlugin.kt
index f4b6354fa8a..3351bc3b7cd 100644
--- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPlugin.kt
+++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPlugin.kt
@@ -419,8 +419,23 @@ class IobCobCalculatorPlugin @Inject constructor(
private var scheduledHistoryPost: ScheduledFuture<*>? = null
private var scheduledEvent: EventNewHistoryData? = null
+ private var lastBgCalcTriggeredAt: Long = 0L
+
@Synchronized
private fun scheduleHistoryDataChange(event: EventNewHistoryData) {
+ val intervalMinutes = preferences.get(IntKey.LoopMinBgRecalcInterval)
+ if (intervalMinutes > 0 && event.reloadBgData && event.newestGlucoseValueTimestamp != null) {
+ val now = System.currentTimeMillis()
+ val intervalMs = intervalMinutes * 60 * 1000L - 10_000L
+ val timeSinceLastCalc = now - lastBgCalcTriggeredAt
+
+ if (timeSinceLastCalc < intervalMs) {
+ aapsLogger.debug(LTag.APS, "Throttled BG recalculation: ${timeSinceLastCalc / 1000}s since last, interval=${intervalMs / 1000}s")
+ return
+ }
+ lastBgCalcTriggeredAt = now
+ }
+
// if there is nothing scheduled or asking reload deeper to the past
if (scheduledEvent == null || event.oldDataTimestamp < (scheduledEvent?.oldDataTimestamp ?: 0L)) {
// cancel waiting task to prevent sending multiple posts
@@ -672,4 +687,4 @@ class IobCobCalculatorPlugin @Inject constructor(
}
return total
}
-}
\ No newline at end of file
+}
diff --git a/plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt b/plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt
new file mode 100644
index 00000000000..0283eb0ecce
--- /dev/null
+++ b/plugins/main/src/test/kotlin/app/aaps/plugins/main/iob/iobCobCalculator/IobCobCalculatorPluginThrottleTest.kt
@@ -0,0 +1,184 @@
+package app.aaps.plugins.main.iob.iobCobCalculator
+
+import app.aaps.core.interfaces.db.PersistenceLayer
+import app.aaps.core.interfaces.db.ProcessedTbrEbData
+import app.aaps.core.interfaces.overview.OverviewData
+import app.aaps.core.interfaces.plugin.ActivePlugin
+import app.aaps.core.interfaces.profile.ProfileFunction
+import app.aaps.core.interfaces.rx.events.EventNewHistoryData
+import app.aaps.core.interfaces.utils.DecimalFormatter
+import app.aaps.core.interfaces.utils.fabric.FabricPrivacy
+import app.aaps.core.interfaces.workflow.CalculationWorkflow
+import app.aaps.core.keys.IntKey
+import app.aaps.shared.tests.TestBase
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.Mock
+import org.mockito.kotlin.any
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import app.aaps.core.interfaces.resources.ResourceHelper
+import app.aaps.core.keys.interfaces.Preferences
+
+class IobCobCalculatorPluginThrottleTest : TestBase() {
+
+ @Mock lateinit var preferences: Preferences
+ @Mock lateinit var rh: ResourceHelper
+ @Mock lateinit var profileFunction: ProfileFunction
+ @Mock lateinit var activePlugin: ActivePlugin
+ @Mock lateinit var fabricPrivacy: FabricPrivacy
+ @Mock lateinit var persistenceLayer: PersistenceLayer
+ @Mock lateinit var overviewData: OverviewData
+ @Mock lateinit var calculationWorkflow: CalculationWorkflow
+ @Mock lateinit var decimalFormatter: DecimalFormatter
+ @Mock lateinit var processedTbrEbData: ProcessedTbrEbData
+ @Mock lateinit var dateUtil: app.aaps.core.interfaces.utils.DateUtil
+
+ private lateinit var sut: IobCobCalculatorPlugin
+
+ private fun getLastBgCalcTriggeredAt(): Long {
+ val field = IobCobCalculatorPlugin::class.java.getDeclaredField("lastBgCalcTriggeredAt")
+ field.isAccessible = true
+ return field.getLong(sut)
+ }
+
+ private fun setLastBgCalcTriggeredAt(value: Long) {
+ val field = IobCobCalculatorPlugin::class.java.getDeclaredField("lastBgCalcTriggeredAt")
+ field.isAccessible = true
+ field.setLong(sut, value)
+ }
+
+ private fun callScheduleHistoryDataChange(event: EventNewHistoryData) {
+ val method = IobCobCalculatorPlugin::class.java.getDeclaredMethod("scheduleHistoryDataChange", EventNewHistoryData::class.java)
+ method.isAccessible = true
+ method.invoke(sut, event)
+ }
+
+ @BeforeEach
+ fun setup() {
+ sut = IobCobCalculatorPlugin(
+ aapsLogger, aapsSchedulers, rxBus, preferences, rh,
+ profileFunction, activePlugin, fabricPrivacy, dateUtil,
+ persistenceLayer, overviewData, calculationWorkflow,
+ decimalFormatter, processedTbrEbData
+ )
+ }
+
+ // --- Throttle disabled (interval = 0) ---
+
+ @Test
+ fun `throttle disabled - BG event always passes through`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(0)
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L)
+
+ callScheduleHistoryDataChange(event)
+ assertThat(getLastBgCalcTriggeredAt()).isEqualTo(0L) // not updated when throttle disabled
+ }
+
+ @Test
+ fun `throttle disabled - rapid BG events all pass through`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(0)
+
+ repeat(5) {
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L + it, reloadBgData = true, newestGlucoseValueTimestamp = 2000L + it)
+ callScheduleHistoryDataChange(event)
+ }
+ assertThat(getLastBgCalcTriggeredAt()).isEqualTo(0L)
+ }
+
+ // --- Throttle enabled ---
+
+ @Test
+ fun `throttle enabled - first BG event passes through and updates timestamp`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3)
+
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L)
+ callScheduleHistoryDataChange(event)
+
+ assertThat(getLastBgCalcTriggeredAt()).isGreaterThan(0L)
+ }
+
+ @Test
+ fun `throttle enabled - second BG event within interval is throttled`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3)
+ setLastBgCalcTriggeredAt(System.currentTimeMillis()) // just ran
+
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L)
+ val before = getLastBgCalcTriggeredAt()
+ callScheduleHistoryDataChange(event)
+
+ // timestamp unchanged = event was throttled (early return before scheduling)
+ assertThat(getLastBgCalcTriggeredAt()).isEqualTo(before)
+ }
+
+ @Test
+ fun `throttle enabled - BG event after interval passes through`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3)
+ // 4 minutes ago — beyond the 3-minute interval
+ setLastBgCalcTriggeredAt(System.currentTimeMillis() - 4 * 60 * 1000L)
+
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L)
+ callScheduleHistoryDataChange(event)
+
+ // timestamp updated = event passed through
+ assertThat(getLastBgCalcTriggeredAt()).isGreaterThan(System.currentTimeMillis() - 1000L)
+ }
+
+ @Test
+ fun `throttle enabled - 10s grace allows slightly early event`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3)
+ // 2 min 55 sec ago — within the 10s grace window of 3-minute interval
+ setLastBgCalcTriggeredAt(System.currentTimeMillis() - (2 * 60 * 1000L + 55 * 1000L))
+
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L)
+ callScheduleHistoryDataChange(event)
+
+ // should pass through due to grace period (interval is 3min - 10s = 2min50s)
+ assertThat(getLastBgCalcTriggeredAt()).isGreaterThan(System.currentTimeMillis() - 1000L)
+ }
+
+ // --- Non-BG events bypass throttle ---
+
+ @Test
+ fun `non-BG event bypasses throttle even within interval`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3)
+ setLastBgCalcTriggeredAt(System.currentTimeMillis()) // just ran
+
+ // reloadBgData = false means this is a treatment/profile change, not a BG event
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = false, newestGlucoseValueTimestamp = null)
+ val before = getLastBgCalcTriggeredAt()
+ callScheduleHistoryDataChange(event)
+
+ // timestamp unchanged but event was NOT throttled (condition doesn't match)
+ assertThat(getLastBgCalcTriggeredAt()).isEqualTo(before)
+ }
+
+ @Test
+ fun `BG event without newestGlucoseValueTimestamp bypasses throttle`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(3)
+ setLastBgCalcTriggeredAt(System.currentTimeMillis()) // just ran
+
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = null)
+ val before = getLastBgCalcTriggeredAt()
+ callScheduleHistoryDataChange(event)
+
+ assertThat(getLastBgCalcTriggeredAt()).isEqualTo(before)
+ }
+
+ // --- Edge cases ---
+
+ @Test
+ fun `throttle with 1 minute interval blocks rapid events`() {
+ whenever(preferences.get(IntKey.LoopMinBgRecalcInterval)).thenReturn(1)
+ setLastBgCalcTriggeredAt(System.currentTimeMillis() - 30_000L) // 30s ago
+
+ val event = EventNewHistoryData(oldDataTimestamp = 1000L, reloadBgData = true, newestGlucoseValueTimestamp = 2000L)
+ val before = getLastBgCalcTriggeredAt()
+ callScheduleHistoryDataChange(event)
+
+ // 30s < 50s (1min - 10s grace), so throttled
+ assertThat(getLastBgCalcTriggeredAt()).isEqualTo(before)
+ }
+}