diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2c46738c9..3963850c9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1546,6 +1546,10 @@
+
+
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Comparison.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Comparison.kt
new file mode 100644
index 000000000..81c093240
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Comparison.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2026 sameerasw.com
+ * License: MIT License
+ *
+ * Feature Module: Domain Layer Models & Registries
+ * File: Comparison.kt
+ * Description: Comparison operators and threshold evaluations with hysteresis.
+ */
+
+package com.sameerasw.essentials.domain.diy
+
+import androidx.annotation.Keep
+import com.google.gson.annotations.SerializedName
+import kotlin.math.abs
+
+@Keep
+data class Comparison(
+ @SerializedName("operator") val operator: Operator = Operator.LTE,
+ @SerializedName("value") val value: Double = 20.0,
+ @SerializedName("upper") val upper: Double? = null,
+ @SerializedName("holdSeconds") val holdSeconds: Int = 0,
+ @SerializedName("hysteresis") val hysteresis: Double? = null,
+) {
+ @Keep
+ enum class Operator(
+ val forStates: Boolean,
+ val isDelta: Boolean,
+ val isRange: Boolean,
+ ) {
+ @SerializedName("GT")
+ GT(true, false, false),
+
+ @SerializedName("GTE")
+ GTE(true, false, false),
+
+ @SerializedName("LT")
+ LT(true, false, false),
+
+ @SerializedName("LTE")
+ LTE(true, false, false),
+
+ @SerializedName("EQ")
+ EQ(true, false, false),
+
+ @SerializedName("NEQ")
+ NEQ(true, false, false),
+
+ @SerializedName("BETWEEN")
+ BETWEEN(true, false, true),
+
+ @SerializedName("OUTSIDE")
+ OUTSIDE(true, false, true),
+
+ @SerializedName("RISES_ABOVE")
+ RISES_ABOVE(false, false, false),
+
+ @SerializedName("FALLS_BELOW")
+ FALLS_BELOW(false, false, false),
+
+ @SerializedName("CHANGES_BY")
+ CHANGES_BY(false, true, false),
+
+ @SerializedName("INCREASES_BY")
+ INCREASES_BY(false, true, false),
+
+ @SerializedName("DECREASES_BY")
+ DECREASES_BY(false, true, false),
+ }
+
+ fun isActive(
+ v: Double,
+ wasActive: Boolean,
+ metric: Metric,
+ ): Boolean {
+ val a = value
+ val b = upper ?: value
+ val h = hysteresis ?: metric.defaultHysteresis
+ val e = metric.step / 2.0
+
+ return if (!wasActive) {
+ when (operator) {
+ Operator.GT, Operator.RISES_ABOVE -> v > a
+ Operator.GTE -> v >= a
+ Operator.LT, Operator.FALLS_BELOW -> v < a
+ Operator.LTE -> v <= a
+ Operator.EQ -> abs(v - a) <= e
+ Operator.NEQ -> abs(v - a) > e
+ Operator.BETWEEN -> v in a..b
+ Operator.OUTSIDE -> v < a || v > b
+ Operator.CHANGES_BY, Operator.INCREASES_BY, Operator.DECREASES_BY -> false
+ }
+ } else {
+ when (operator) {
+ Operator.GT, Operator.RISES_ABOVE -> v > a - h
+ Operator.GTE -> v >= a - h
+ Operator.LT, Operator.FALLS_BELOW -> v < a + h
+ Operator.LTE -> v <= a + h
+ Operator.EQ -> abs(v - a) <= e + h
+ Operator.NEQ -> abs(v - a) > e
+ Operator.BETWEEN -> v >= a - h && v <= b + h
+ Operator.OUTSIDE -> {
+ val effH = if (b - a <= 2.0 * h) 0.0 else h
+ v < a + effH || v > b - effH
+ }
+ Operator.CHANGES_BY, Operator.INCREASES_BY, Operator.DECREASES_BY -> false
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt
index 8fdb2a0e9..6423f4837 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRepository.kt
@@ -76,6 +76,7 @@ object DIYRepository {
private var appContext: Context? = null
fun init(context: Context) {
+ DIYRuntimeStore.init(context)
appContext = context.applicationContext
if (prefs != null) return
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -113,6 +114,7 @@ object DIYRepository {
}
fun updateAutomation(automation: Automation) {
+ DIYRuntimeStore.setPaused(automation.id, false)
val current = _automations.value.toMutableList()
val index = current.indexOfFirst { it.id == automation.id }
if (index != -1) {
@@ -124,6 +126,7 @@ object DIYRepository {
}
fun removeAutomation(id: String) {
+ DIYRuntimeStore.setPaused(id, false)
val current = _automations.value.toMutableList()
current.removeAll { it.id == id }
_automations.value = current
@@ -131,6 +134,18 @@ object DIYRepository {
updateActionShortcutLauncherState()
}
+ fun pauseAutomation(id: String) {
+ val current = _automations.value.toMutableList()
+ val index = current.indexOfFirst { it.id == id }
+ if (index != -1) {
+ current[index] = current[index].copy(isEnabled = false)
+ _automations.value = current
+ saveToPrefs()
+ updateActionShortcutLauncherState()
+ DIYRuntimeStore.setPaused(id, true)
+ }
+ }
+
fun getAutomation(id: String): Automation? = _automations.value.find { it.id == id }
private fun updateActionShortcutLauncherState() {
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRuntimeStore.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRuntimeStore.kt
new file mode 100644
index 000000000..f3b7e7868
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/DIYRuntimeStore.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 sameerasw.com
+ * License: MIT License
+ *
+ * Feature Module: Domain Layer Models & Registries
+ * File: DIYRuntimeStore.kt
+ * Description: Storage for device-local DIY runtime state.
+ */
+
+package com.sameerasw.essentials.domain.diy
+
+import android.content.Context
+import android.content.SharedPreferences
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+// Holds device-local runtime state and is deliberately left out of settings export.
+object DIYRuntimeStore {
+ private const val PREFS_NAME = "diy_runtime_prefs"
+ private const val KEY_PAUSED_IDS = "loop_paused_ids"
+ private const val KEY_OPEN_STATES_PREFIX = "open_states_"
+
+ private var prefs: SharedPreferences? = null
+ private val _pausedIds = MutableStateFlow>(emptySet())
+ val pausedIds: StateFlow> = _pausedIds.asStateFlow()
+
+ fun init(context: Context) {
+ if (prefs != null) return
+ val sharedPreferences = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ prefs = sharedPreferences
+ _pausedIds.value = sharedPreferences.getStringSet(KEY_PAUSED_IDS, emptySet())?.toSet() ?: emptySet()
+ }
+
+ fun setPaused(
+ id: String,
+ paused: Boolean,
+ ) {
+ val current = _pausedIds.value.toMutableSet()
+ val changed = if (paused) current.add(id) else current.remove(id)
+ if (changed) {
+ val newSet = current.toSet()
+ _pausedIds.value = newSet
+ prefs?.edit()?.putStringSet(KEY_PAUSED_IDS, newSet)?.apply()
+ }
+ }
+
+ fun openStateIds(moduleId: String): Set =
+ prefs?.getStringSet(KEY_OPEN_STATES_PREFIX + moduleId, emptySet())?.toSet() ?: emptySet()
+
+ fun setOpenStateIds(
+ moduleId: String,
+ ids: Set,
+ ) {
+ prefs?.edit()?.putStringSet(KEY_OPEN_STATES_PREFIX + moduleId, ids.toSet())?.apply()
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Metric.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Metric.kt
new file mode 100644
index 000000000..5b2589540
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Metric.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2026 sameerasw.com
+ * License: MIT License
+ *
+ * Feature Module: Domain Layer Models & Registries
+ * File: Metric.kt
+ * Description: Measurable numeric values for value conditions.
+ */
+
+package com.sameerasw.essentials.domain.diy
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.Keep
+import androidx.annotation.StringRes
+import com.sameerasw.essentials.R
+
+@Keep
+enum class Metric(
+ @StringRes val title: Int,
+ @DrawableRes val icon: Int,
+ val min: Double,
+ val max: Double,
+ val step: Double,
+ val defaultHysteresis: Double,
+) {
+ BATTERY_LEVEL(
+ R.string.diy_metric_battery_level,
+ R.drawable.rounded_battery_android_frame_3_24,
+ 0.0,
+ 100.0,
+ 1.0,
+ 2.0,
+ ),
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt
index dd62773b8..d6610361a 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/State.kt
@@ -74,4 +74,13 @@ sealed interface State {
override val title: Int get() = R.string.diy_state_calendar_event
override val icon: Int get() = R.drawable.rounded_calendar_today_24
}
+
+ @Keep
+ data class SystemMetric(
+ @SerializedName("metric") val metric: Metric = Metric.BATTERY_LEVEL,
+ @SerializedName("comparison") val comparison: Comparison = Comparison(),
+ ) : State {
+ override val title: Int get() = metric.title
+ override val icon: Int get() = metric.icon
+ }
}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricText.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricText.kt
new file mode 100644
index 000000000..cab07f84b
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricText.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2026 sameerasw.com
+ * License: MIT License
+ *
+ * Feature Module: Domain Layer Models & Registries
+ * File: SystemMetricText.kt
+ * Description: Formatting functions for system metric conditions.
+ */
+
+package com.sameerasw.essentials.domain.diy
+
+import android.content.Context
+import com.sameerasw.essentials.R
+import kotlin.math.roundToInt
+
+fun systemMetricText(
+ context: Context,
+ metric: Metric,
+ comparison: Comparison,
+): String {
+ val metricTitle = context.getString(metric.title)
+ val formattedValue = context.getString(R.string.diy_metric_percent, comparison.value.roundToInt())
+ val formattedUpper =
+ comparison.upper?.let {
+ context.getString(R.string.diy_metric_percent, it.roundToInt())
+ } ?: formattedValue
+
+ val formatRes =
+ when (comparison.operator) {
+ Comparison.Operator.GT -> R.string.diy_metric_summary_gt
+ Comparison.Operator.GTE -> R.string.diy_metric_summary_gte
+ Comparison.Operator.LT -> R.string.diy_metric_summary_lt
+ Comparison.Operator.LTE -> R.string.diy_metric_summary_lte
+ Comparison.Operator.EQ -> R.string.diy_metric_summary_eq
+ Comparison.Operator.NEQ -> R.string.diy_metric_summary_neq
+ Comparison.Operator.BETWEEN -> R.string.diy_metric_summary_between
+ Comparison.Operator.OUTSIDE -> R.string.diy_metric_summary_outside
+ Comparison.Operator.RISES_ABOVE -> R.string.diy_metric_summary_rises_above
+ Comparison.Operator.FALLS_BELOW -> R.string.diy_metric_summary_falls_below
+ Comparison.Operator.CHANGES_BY -> R.string.diy_metric_summary_changes_by
+ Comparison.Operator.INCREASES_BY -> R.string.diy_metric_summary_increases_by
+ Comparison.Operator.DECREASES_BY -> R.string.diy_metric_summary_decreases_by
+ }
+ return context.getString(formatRes, metricTitle, formattedValue, formattedUpper)
+}
+
+fun conditionText(
+ context: Context,
+ automation: Automation,
+): String =
+ when (automation.type) {
+ Automation.Type.TRIGGER ->
+ when (val trigger = automation.trigger) {
+ is Trigger.SystemMetric -> systemMetricText(context, trigger.metric, trigger.comparison)
+ else -> ""
+ }
+ Automation.Type.STATE ->
+ when (val state = automation.state) {
+ is State.SystemMetric -> systemMetricText(context, state.metric, state.comparison)
+ is State.BatteryLevel -> context.getString(R.string.diy_battery_level_range, state.minLevel, state.maxLevel)
+ else -> ""
+ }
+ else -> ""
+ }
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricTracker.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricTracker.kt
new file mode 100644
index 000000000..962f5c449
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/SystemMetricTracker.kt
@@ -0,0 +1,394 @@
+/*
+ * Copyright (c) 2026 sameerasw.com
+ * License: MIT License
+ *
+ * Feature Module: Domain Layer Models & Registries
+ * File: SystemMetricTracker.kt
+ * Description: Pure Kotlin tracker for numeric value triggers, states, holds, and loop breaking.
+ */
+
+package com.sameerasw.essentials.domain.diy
+
+import kotlin.math.abs
+
+class SystemMetricTracker(
+ private val clock: () -> Long,
+ private val scheduler: HoldScheduler,
+ private val openStore: OpenStateStore,
+ private val listener: Listener,
+) {
+ fun interface HoldScheduler {
+ fun schedule(
+ delayMs: Long,
+ block: () -> Unit,
+ ): () -> Unit
+ }
+
+ interface OpenStateStore {
+ fun load(): Set
+
+ fun save(ids: Set)
+ }
+
+ interface Listener {
+ fun onFire(automation: Automation)
+
+ fun onEnter(automation: Automation)
+
+ fun onExit(automation: Automation)
+
+ fun onLoopBreak(automation: Automation)
+ }
+
+ private data class ConditionKey(
+ val metric: Metric,
+ val comparison: Comparison,
+ val cooldownSeconds: Int,
+ )
+
+ private class AutomationRecord(
+ var automation: Automation,
+ val metric: Metric,
+ val comparison: Comparison,
+ val cooldownSeconds: Int,
+ ) {
+ var hasBaseline: Boolean = false
+ var active: Boolean = false
+ var open: Boolean = false
+ var deltaRef: Double? = null
+ var lastFireTime: Long? = null
+ var cancelHold: (() -> Unit)? = null
+ val recentTimestamps = mutableListOf()
+ var tripped: Boolean = false
+
+ fun conditionKey(): ConditionKey = ConditionKey(metric, comparison, cooldownSeconds)
+ }
+
+ private val records = mutableMapOf()
+ private val latestReadings = mutableMapOf()
+ private val restoredOpenIds = openStore.load().toMutableSet()
+ private var firstUpdate = true
+
+ fun updateAutomations(automations: List) {
+ val relevantAutomations = automations.filter { extractConfig(it) != null }
+
+ if (firstUpdate) {
+ firstUpdate = false
+ val relevantIds = relevantAutomations.map { it.id }.toSet()
+ val dropped = restoredOpenIds.filter { it !in relevantIds }
+ if (dropped.isNotEmpty()) {
+ restoredOpenIds.removeAll(dropped.toSet())
+ saveOpenState()
+ }
+ }
+
+ val newRecords = mutableMapOf()
+ val newIds = relevantAutomations.map { it.id }.toSet()
+
+ for ((id, oldRecord) in records) {
+ if (id !in newIds) {
+ oldRecord.cancelHold?.invoke()
+ oldRecord.cancelHold = null
+ if (oldRecord.open) {
+ oldRecord.open = false
+ saveOpenState()
+ listener.onExit(oldRecord.automation)
+ } else if (!oldRecord.hasBaseline && id in restoredOpenIds) {
+ restoredOpenIds.remove(id)
+ saveOpenState()
+ listener.onExit(oldRecord.automation)
+ }
+ }
+ }
+
+ for (automation in relevantAutomations) {
+ val (metric, comparison) = extractConfig(automation) ?: continue
+ val cooldown = (automation.trigger as? Trigger.SystemMetric)?.cooldownSeconds ?: 0
+ val newKey = ConditionKey(metric, comparison, cooldown)
+
+ val existing = records[automation.id]
+ if (existing != null) {
+ if (existing.conditionKey() == newKey) {
+ existing.automation = automation
+ newRecords[automation.id] = existing
+ } else {
+ // Condition changed: treat as remove + add
+ existing.cancelHold?.invoke()
+ existing.cancelHold = null
+ if (existing.open) {
+ existing.open = false
+ saveOpenState()
+ listener.onExit(existing.automation)
+ }
+ val newRecord = AutomationRecord(automation, metric, comparison, cooldown)
+ newRecords[automation.id] = newRecord
+ val currentVal = latestReadings[metric]
+ if (currentVal != null) {
+ baselineRecord(newRecord, currentVal)
+ }
+ }
+ } else {
+ val newRecord = AutomationRecord(automation, metric, comparison, cooldown)
+ newRecords[automation.id] = newRecord
+ val currentVal = latestReadings[metric]
+ if (currentVal != null) {
+ baselineRecord(newRecord, currentVal)
+ }
+ }
+ }
+
+ records.clear()
+ records.putAll(newRecords)
+ saveOpenState()
+ }
+
+ fun onReading(
+ metric: Metric,
+ value: Double,
+ ) {
+ if (latestReadings[metric] == value) return
+ latestReadings[metric] = value
+
+ for (record in records.values) {
+ if (record.metric != metric || record.tripped) continue
+
+ if (!record.hasBaseline) {
+ baselineRecord(record, value)
+ continue
+ }
+
+ if (record.comparison.operator.isDelta) {
+ evaluateDeltaTrigger(record, value)
+ } else {
+ evaluateLevelCondition(record, value)
+ }
+ }
+ }
+
+ fun closeAll() {
+ for (record in records.values) {
+ record.cancelHold?.invoke()
+ record.cancelHold = null
+ if (record.open) {
+ record.open = false
+ listener.onExit(record.automation)
+ } else if (!record.hasBaseline && record.automation.id in restoredOpenIds) {
+ restoredOpenIds.remove(record.automation.id)
+ listener.onExit(record.automation)
+ }
+ }
+ records.clear()
+ restoredOpenIds.clear()
+ openStore.save(emptySet())
+ }
+
+ fun currentValue(metric: Metric): Double? = latestReadings[metric]
+
+ private fun extractConfig(automation: Automation): Pair? {
+ return when (automation.type) {
+ Automation.Type.TRIGGER -> {
+ val trigger = automation.trigger as? Trigger.SystemMetric ?: return null
+ Pair(trigger.metric, trigger.comparison)
+ }
+ Automation.Type.STATE -> {
+ when (val state = automation.state) {
+ is State.SystemMetric -> Pair(state.metric, state.comparison)
+ is State.BatteryLevel ->
+ Pair(
+ Metric.BATTERY_LEVEL,
+ Comparison(
+ operator = Comparison.Operator.BETWEEN,
+ value = state.minLevel.toDouble(),
+ upper = state.maxLevel.toDouble(),
+ holdSeconds = 0,
+ hysteresis = 0.0,
+ ),
+ )
+ else -> null
+ }
+ }
+ else -> null
+ }
+ }
+
+ private fun baselineRecord(
+ record: AutomationRecord,
+ v: Double,
+ ) {
+ record.hasBaseline = true
+ val isRestored = restoredOpenIds.remove(record.automation.id)
+
+ if (record.comparison.operator.isDelta) {
+ record.deltaRef = v
+ return
+ }
+
+ val isActive = record.comparison.isActive(v, wasActive = false, metric = record.metric)
+ record.active = isActive
+
+ if (record.automation.type == Automation.Type.STATE) {
+ if (isRestored) {
+ if (isActive) {
+ record.open = true
+ saveOpenState()
+ } else {
+ record.open = false
+ saveOpenState()
+ listener.onExit(record.automation)
+ }
+ } else {
+ if (isActive) {
+ if (record.comparison.holdSeconds > 0) {
+ scheduleHold(record)
+ } else {
+ executeEnter(record)
+ }
+ }
+ }
+ }
+ }
+
+ private fun scheduleHold(record: AutomationRecord) {
+ record.cancelHold?.invoke()
+ val delayMs = record.comparison.holdSeconds * 1000L
+ record.cancelHold =
+ scheduler.schedule(delayMs) {
+ record.cancelHold = null
+ if (!record.tripped && record.active) {
+ if (record.automation.type == Automation.Type.TRIGGER) {
+ executeFire(record)
+ } else if (record.automation.type == Automation.Type.STATE && !record.open) {
+ executeEnter(record)
+ }
+ }
+ }
+ }
+
+ private fun executeFire(record: AutomationRecord) {
+ if (!checkLoopBreaker(record)) return
+ record.lastFireTime = clock()
+ listener.onFire(record.automation)
+ }
+
+ private fun executeEnter(record: AutomationRecord) {
+ if (!checkLoopBreaker(record)) return
+ record.open = true
+ saveOpenState()
+ listener.onEnter(record.automation)
+ }
+
+ private fun checkLoopBreaker(record: AutomationRecord): Boolean {
+ val now = clock()
+ record.recentTimestamps.retainAll { now - it < LOOP_WINDOW_MS }
+ if (record.recentTimestamps.size >= LOOP_LIMIT) {
+ record.tripped = true
+ if (record.open) {
+ record.open = false
+ saveOpenState()
+ listener.onExit(record.automation)
+ }
+ listener.onLoopBreak(record.automation)
+ return false
+ }
+ record.recentTimestamps.add(now)
+ return true
+ }
+
+ private fun evaluateDeltaTrigger(
+ record: AutomationRecord,
+ v: Double,
+ ) {
+ val ref =
+ record.deltaRef ?: run {
+ record.deltaRef = v
+ return
+ }
+
+ val a = record.comparison.value
+ val shouldFire =
+ when (record.comparison.operator) {
+ Comparison.Operator.CHANGES_BY -> abs(v - ref) >= a
+ Comparison.Operator.INCREASES_BY -> {
+ val met = v - ref >= a
+ if (!met) {
+ record.deltaRef = minOf(ref, v)
+ }
+ met
+ }
+ Comparison.Operator.DECREASES_BY -> {
+ val met = ref - v >= a
+ if (!met) {
+ record.deltaRef = maxOf(ref, v)
+ }
+ met
+ }
+ else -> false
+ }
+
+ if (shouldFire) {
+ val now = clock()
+ val cooldownMs = maxOf(record.cooldownSeconds, Trigger.MIN_VALUE_COOLDOWN_SECONDS) * 1000L
+ val last = record.lastFireTime
+ if (last == null || now - last >= cooldownMs) {
+ if (checkLoopBreaker(record)) {
+ record.lastFireTime = now
+ record.deltaRef = v
+ listener.onFire(record.automation)
+ }
+ }
+ }
+ }
+
+ private fun evaluateLevelCondition(
+ record: AutomationRecord,
+ v: Double,
+ ) {
+ val wasActive = record.active
+ val isActive = record.comparison.isActive(v, wasActive, record.metric)
+ record.active = isActive
+
+ if (!wasActive && isActive) {
+ if (record.automation.type == Automation.Type.TRIGGER) {
+ val now = clock()
+ val cooldownMs = maxOf(record.cooldownSeconds, Trigger.MIN_VALUE_COOLDOWN_SECONDS) * 1000L
+ val last = record.lastFireTime
+ if (last == null || now - last >= cooldownMs) {
+ if (record.comparison.holdSeconds > 0) {
+ scheduleHold(record)
+ } else {
+ executeFire(record)
+ }
+ }
+ } else if (record.automation.type == Automation.Type.STATE) {
+ if (record.comparison.holdSeconds > 0) {
+ scheduleHold(record)
+ } else {
+ executeEnter(record)
+ }
+ }
+ } else if (wasActive && !isActive) {
+ record.cancelHold?.invoke()
+ record.cancelHold = null
+
+ if (record.automation.type == Automation.Type.STATE && record.open) {
+ record.open = false
+ saveOpenState()
+ listener.onExit(record.automation)
+ }
+ }
+ }
+
+ private fun saveOpenState() {
+ val openIds =
+ records.values
+ .filter { it.open }
+ .map { it.automation.id }
+ .toSet() + restoredOpenIds
+ openStore.save(openIds)
+ }
+
+ companion object {
+ const val LOOP_LIMIT = 5
+ const val LOOP_WINDOW_MS = 60_000L
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
index 779f56b22..3d8cb53a4 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
@@ -206,9 +206,22 @@ sealed interface Trigger {
override val icon: Int = R.drawable.rounded_directions_car_24
}
+ @Keep
+ data class SystemMetric(
+ @SerializedName("metric") val metric: Metric = Metric.BATTERY_LEVEL,
+ @SerializedName("comparison") val comparison: Comparison = Comparison(),
+ @SerializedName("cooldownSeconds") val cooldownSeconds: Int = DEFAULT_VALUE_COOLDOWN_SECONDS,
+ ) : Trigger {
+ override val title: Int get() = metric.title
+ override val icon: Int get() = metric.icon
+ override val isConfigurable: Boolean get() = true
+ }
+
companion object {
const val DEFAULT_LOCATION_RADIUS_METERS = 200
const val MIN_LOCATION_RADIUS_METERS = 100
const val MAX_LOCATION_RADIUS_METERS = 5000
+ const val DEFAULT_VALUE_COOLDOWN_SECONDS = 60
+ const val MIN_VALUE_COOLDOWN_SECONDS = 5
}
}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
index b2580e51b..48e43e2e9 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
@@ -123,7 +123,7 @@ object AutomationManager {
when (automation.type) {
Automation.Type.TRIGGER -> {
when (automation.trigger) {
- is Trigger.ChargerConnected, is Trigger.ChargerDisconnected, is Trigger.PowerSavingOn, is Trigger.PowerSavingOff -> {
+ is Trigger.ChargerConnected, is Trigger.ChargerDisconnected, is Trigger.PowerSavingOn, is Trigger.PowerSavingOff, is Trigger.SystemMetric -> {
requiredModuleIds.add(PowerModule.ID)
powerAutomations.add(automation)
}
@@ -169,7 +169,7 @@ object AutomationManager {
Automation.Type.STATE -> {
when (automation.state) {
- is DIYState.Charging, is DIYState.PowerSaving, is DIYState.BatteryLevel -> {
+ is DIYState.Charging, is DIYState.PowerSaving, is DIYState.BatteryLevel, is DIYState.SystemMetric -> {
requiredModuleIds.add(PowerModule.ID)
powerAutomations.add(automation)
}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt
index 62c21b6c1..be4a4efeb 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/PowerModule.kt
@@ -14,13 +14,25 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
+import android.os.Handler
+import android.os.Looper
import android.os.PowerManager
+import android.os.SystemClock
+import com.sameerasw.essentials.domain.diy.Action
import com.sameerasw.essentials.domain.diy.Automation
+import com.sameerasw.essentials.domain.diy.DIYRepository
+import com.sameerasw.essentials.domain.diy.DIYRuntimeStore
+import com.sameerasw.essentials.domain.diy.Metric
+import com.sameerasw.essentials.domain.diy.SystemMetricTracker
import com.sameerasw.essentials.domain.diy.Trigger
import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor
+import com.sameerasw.essentials.utils.AutomationFailureNotifier
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import com.sameerasw.essentials.domain.diy.State as DIYState
class PowerModule : AutomationModule {
@@ -31,13 +43,31 @@ class PowerModule : AutomationModule {
override val id: String = ID
private var automations: List = emptyList()
private val scope = CoroutineScope(Dispatchers.IO)
+ private var tracker: SystemMetricTracker? = null
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val automationMutexes = mutableMapOf()
// State tracking
private var isCharging = false
private var isPowerSaving = false
private var appContext: Context? = null
private var batteryLevel = -1
- private val batteryStateActive = mutableMapOf()
+
+ private fun getMutex(id: String): Mutex = automationMutexes.getOrPut(id) { Mutex() }
+
+ private fun runOrdered(
+ automation: Automation,
+ actions: List,
+ ) {
+ if (actions.isEmpty()) return
+ val context = appContext ?: return
+ val mutex = getMutex(automation.id)
+ scope.launch(start = CoroutineStart.UNDISPATCHED) {
+ mutex.withLock {
+ CombinedActionExecutor.executeAll(context, actions)
+ }
+ }
+ }
private val receiver =
object : BroadcastReceiver() {
@@ -69,7 +99,8 @@ class PowerModule : AutomationModule {
val percent = level * 100 / scale
if (percent != batteryLevel) {
batteryLevel = percent
- evaluateBatteryLevel(context)
+ // The broadcast fires on any battery field change, so only forward level changes.
+ tracker?.onReading(Metric.BATTERY_LEVEL, percent.toDouble())
}
}
}
@@ -101,6 +132,44 @@ class PowerModule : AutomationModule {
addAction(Intent.ACTION_BATTERY_CHANGED)
}
appContext = context.applicationContext
+ DIYRuntimeStore.init(context)
+ tracker =
+ SystemMetricTracker(
+ clock = { SystemClock.elapsedRealtime() },
+ scheduler = { delayMs, block ->
+ val runnable = Runnable { block() }
+ mainHandler.postDelayed(runnable, delayMs)
+ val cancel: () -> Unit = { mainHandler.removeCallbacks(runnable) }
+ cancel
+ },
+ openStore =
+ object : SystemMetricTracker.OpenStateStore {
+ override fun load(): Set = DIYRuntimeStore.openStateIds(ID)
+
+ override fun save(ids: Set) = DIYRuntimeStore.setOpenStateIds(ID, ids)
+ },
+ listener =
+ object : SystemMetricTracker.Listener {
+ override fun onFire(automation: Automation) {
+ runOrdered(automation, automation.actionList)
+ }
+
+ override fun onEnter(automation: Automation) {
+ runOrdered(automation, automation.entryActionList)
+ }
+
+ override fun onExit(automation: Automation) {
+ runOrdered(automation, automation.exitActionList)
+ }
+
+ override fun onLoopBreak(automation: Automation) {
+ DIYRepository.pauseAutomation(automation.id)
+ appContext?.let {
+ AutomationFailureNotifier.notifyLoopBreak(it, automation)
+ }
+ }
+ },
+ )
context.registerReceiver(receiver, filter)
// Initial check for charging
@@ -125,36 +194,21 @@ class PowerModule : AutomationModule {
}
override fun stop(context: Context) {
+ tracker?.closeAll()
+ tracker = null
+ mainHandler.removeCallbacksAndMessages(null)
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
// Ignore if not registered
}
- batteryStateActive.clear()
batteryLevel = -1
appContext = null
}
override fun updateAutomations(automations: List) {
this.automations = automations
- batteryStateActive.keys.retainAll(automations.map { it.id }.toSet())
- appContext?.let { evaluateBatteryLevel(it) }
- }
-
- private fun evaluateBatteryLevel(context: Context) {
- val level = batteryLevel
- if (level < 0) return
- automations
- .filter { it.type == Automation.Type.STATE && it.isEnabled }
- .forEach { automation ->
- val state = automation.state as? DIYState.BatteryLevel ?: return@forEach
- val inRange = level in state.minLevel..state.maxLevel
- val previous = batteryStateActive.put(automation.id, inRange)
- if (inRange == (previous ?: false)) return@forEach
- val actions = if (inRange) automation.entryActionList else automation.exitActionList
- if (actions.isEmpty()) return@forEach
- scope.launch { CombinedActionExecutor.executeAll(context, actions) }
- }
+ tracker?.updateAutomations(automations)
}
private fun handleTrigger(
diff --git a/app/src/main/java/com/sameerasw/essentials/services/receivers/LoopBreakerReceiver.kt b/app/src/main/java/com/sameerasw/essentials/services/receivers/LoopBreakerReceiver.kt
new file mode 100644
index 000000000..9fc31c5fa
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/receivers/LoopBreakerReceiver.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 sameerasw.com
+ * License: MIT License
+ *
+ * Feature Module: Background Services & Receivers
+ * File: LoopBreakerReceiver.kt
+ * Description: Receiver to re-enable automations paused by the loop breaker.
+ */
+
+package com.sameerasw.essentials.services.receivers
+
+import android.app.NotificationManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.sameerasw.essentials.domain.diy.DIYRepository
+
+class LoopBreakerReceiver : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
+ val automationId = intent.getStringExtra("automation_id") ?: return
+ DIYRepository.init(context)
+ val automation = DIYRepository.getAutomation(automationId) ?: return
+ DIYRepository.updateAutomation(automation.copy(isEnabled = true))
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.cancel(automationId.hashCode())
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
index e878b624f..afd3ec66f 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
@@ -87,9 +87,11 @@ import com.sameerasw.essentials.data.repository.SettingsRepository
import com.sameerasw.essentials.domain.diy.Action
import com.sameerasw.essentials.domain.diy.ActionRegistry
import com.sameerasw.essentials.domain.diy.Automation
-import com.sameerasw.essentials.domain.model.AppPermission
+import com.sameerasw.essentials.domain.diy.Comparison
import com.sameerasw.essentials.domain.diy.DIYRepository
+import com.sameerasw.essentials.domain.diy.Metric
import com.sameerasw.essentials.domain.diy.Trigger
+import com.sameerasw.essentials.domain.model.AppPermission
import com.sameerasw.essentials.domain.model.AppSelection
import com.sameerasw.essentials.domain.model.NotificationApp
import com.sameerasw.essentials.ui.components.CategoryExpandableSection
@@ -99,6 +101,7 @@ import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer
import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker
import com.sameerasw.essentials.ui.core.sheets.BluetoothDeviceSelectionSheet
import com.sameerasw.essentials.ui.core.sheets.NotificationTriggerSheet
+import com.sameerasw.essentials.ui.core.sheets.SystemMetricSheet
import com.sameerasw.essentials.ui.core.sheets.WifiNetworkSelectionSheet
import com.sameerasw.essentials.ui.features.location.sheets.LocationTriggerSheet
import com.sameerasw.essentials.ui.features.system.ActionSequenceEditor
@@ -208,7 +211,25 @@ class AutomationEditorActivity : ComponentActivity() {
// State for selections
// Initialize with existing data or defaults
var selectedTrigger by remember { mutableStateOf(existingAutomation?.trigger) }
- var selectedState by remember { mutableStateOf(existingAutomation?.state) }
+ var selectedState by remember {
+ val state = existingAutomation?.state
+ val initial =
+ if (state is DIYState.BatteryLevel) {
+ DIYState.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison =
+ Comparison(
+ operator = Comparison.Operator.BETWEEN,
+ value = state.minLevel.toDouble(),
+ upper = state.maxLevel.toDouble(),
+ hysteresis = 0.0,
+ ),
+ )
+ } else {
+ state
+ }
+ mutableStateOf(initial)
+ }
var selectedApps by remember {
mutableStateOf>(
existingAutomation?.selectedApps ?: emptyList(),
@@ -358,10 +379,10 @@ class AutomationEditorActivity : ComponentActivity() {
// Config Sheets
var showTimeSettings by remember { mutableStateOf(false) }
var showCalendarStateSettings by remember { mutableStateOf(false) }
- var showBatteryLevelSettings by remember { mutableStateOf(false) }
var showBluetoothSettings by remember { mutableStateOf(false) }
var showWifiSettings by remember { mutableStateOf(false) }
var showNotificationTriggerSettings by remember { mutableStateOf(false) }
+ var showSystemMetricSettings by remember { mutableStateOf(false) }
var editingLocationTrigger by remember { mutableStateOf(null) }
val isTriggerConfigured =
@@ -1012,6 +1033,11 @@ class AutomationEditorActivity : ComponentActivity() {
Trigger.ChargerDisconnected,
Trigger.PowerSavingOn,
Trigger.PowerSavingOff,
+ (selectedTrigger as? Trigger.SystemMetric)?.takeIf {
+ it.metric ==
+ Metric.BATTERY_LEVEL
+ }
+ ?: Trigger.SystemMetric(metric = Metric.BATTERY_LEVEL),
),
R.string.diy_category_connectivity to
listOf(
@@ -1071,7 +1097,12 @@ class AutomationEditorActivity : ComponentActivity() {
.firstOrNull { (_, list) ->
list.any {
selectedTrigger != null &&
- it::class == selectedTrigger!!::class
+ it::class == selectedTrigger!!::class &&
+ (
+ it !is Trigger.SystemMetric ||
+ (selectedTrigger as? Trigger.SystemMetric)?.metric ==
+ it.metric
+ )
}
}?.first ?: triggerCategories.firstOrNull()?.first,
)
@@ -1095,7 +1126,13 @@ class AutomationEditorActivity : ComponentActivity() {
) {
triggerList.forEach { trigger ->
val isSelected =
- selectedTrigger != null && selectedTrigger!!::class == trigger::class
+ selectedTrigger != null &&
+ selectedTrigger!!::class == trigger::class &&
+ (
+ trigger !is Trigger.SystemMetric ||
+ (selectedTrigger as? Trigger.SystemMetric)?.metric ==
+ trigger.metric
+ )
EditorActionItem(
title = stringResource(trigger.title),
iconRes = trigger.icon,
@@ -1119,6 +1156,10 @@ class AutomationEditorActivity : ComponentActivity() {
true
is Trigger.NotificationReceived -> showNotificationTriggerSettings = true
is Trigger.ArriveAtLocation, is Trigger.LeaveLocation -> editingLocationTrigger = trigger
+ is Trigger.SystemMetric -> {
+ selectedTrigger = trigger
+ showSystemMetricSettings = true
+ }
else -> {}
}
},
@@ -1134,7 +1175,11 @@ class AutomationEditorActivity : ComponentActivity() {
listOf(
DIYState.Charging,
DIYState.PowerSaving,
- (selectedState as? DIYState.BatteryLevel) ?: DIYState.BatteryLevel(),
+ (selectedState as? DIYState.SystemMetric)?.takeIf {
+ it.metric ==
+ Metric.BATTERY_LEVEL
+ }
+ ?: DIYState.SystemMetric(metric = Metric.BATTERY_LEVEL),
),
R.string.diy_category_system_screen to
listOf(
@@ -1166,7 +1211,13 @@ class AutomationEditorActivity : ComponentActivity() {
stateCategories
.firstOrNull { (_, list) ->
list.any {
- selectedState != null && it::class == selectedState!!::class
+ selectedState != null &&
+ it::class == selectedState!!::class &&
+ (
+ it !is DIYState.SystemMetric ||
+ (selectedState as? DIYState.SystemMetric)?.metric ==
+ it.metric
+ )
}
}?.first ?: stateCategories.firstOrNull()?.first,
)
@@ -1190,25 +1241,34 @@ class AutomationEditorActivity : ComponentActivity() {
) {
stateList.forEach { state ->
val isSelected =
- selectedState != null && selectedState!!::class == state::class
+ selectedState != null &&
+ selectedState!!::class == state::class &&
+ (
+ state !is DIYState.SystemMetric ||
+ (selectedState as? DIYState.SystemMetric)?.metric ==
+ state.metric
+ )
EditorActionItem(
title = stringResource(state.title),
iconRes = state.icon,
isSelected = isSelected,
onClick = { selectedState = state },
- isConfigurable = state is DIYState.TimePeriod || state is DIYState.CalendarEvent || state is DIYState.BatteryLevel,
+ isConfigurable =
+ state is DIYState.TimePeriod ||
+ state is DIYState.CalendarEvent ||
+ state is DIYState.SystemMetric,
onSettingsClick = {
if (state is DIYState.TimePeriod) {
showTimeSettings = true
}
- if (state is DIYState.BatteryLevel) {
- selectedState = state
- showBatteryLevelSettings = true
- }
if (state is DIYState.CalendarEvent) {
selectedState = state
showCalendarStateSettings = true
}
+ if (state is DIYState.SystemMetric) {
+ selectedState = state
+ showSystemMetricSettings = true
+ }
},
)
}
@@ -1327,10 +1387,12 @@ class AutomationEditorActivity : ComponentActivity() {
}
}
- if (showBatteryLevelSettings) {
- com.sameerasw.essentials.ui.core.sheets.BatteryLevelStateSheet(
- initialState = selectedState as? DIYState.BatteryLevel,
- onDismiss = { showBatteryLevelSettings = false },
+ if (showSystemMetricSettings) {
+ SystemMetricSheet(
+ initialTrigger = selectedTrigger as? Trigger.SystemMetric,
+ initialState = selectedState as? DIYState.SystemMetric,
+ onDismiss = { showSystemMetricSettings = false },
+ onTriggerChange = { selectedTrigger = it },
onStateChange = { selectedState = it },
)
}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/BatteryLevelStateSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/BatteryLevelStateSheet.kt
deleted file mode 100644
index 8722bf6f6..000000000
--- a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/BatteryLevelStateSheet.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright (c) 2026 sameerasw.com
- * License: MIT License
- *
- * Feature Module: Core UI Sheets
- * File: BatteryLevelStateSheet.kt
- * Description: Configures the battery level DIY state range.
- */
-
-package com.sameerasw.essentials.ui.core.sheets
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.RangeSlider
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.sameerasw.essentials.R
-import com.sameerasw.essentials.utils.HapticUtil
-import kotlin.math.roundToInt
-import com.sameerasw.essentials.domain.diy.State as DIYState
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun BatteryLevelStateSheet(
- initialState: DIYState.BatteryLevel?,
- onDismiss: () -> Unit,
- onStateChange: (DIYState.BatteryLevel) -> Unit,
-) {
- val view = LocalView.current
- var state by remember { mutableStateOf(initialState ?: DIYState.BatteryLevel()) }
-
- EssentialsBottomSheet(onDismissRequest = onDismiss) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp)
- .padding(bottom = 32.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- Text(
- text = stringResource(R.string.diy_state_battery_level),
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(start = 8.dp),
- )
- Text(
- text = stringResource(R.string.diy_battery_level_range, state.minLevel, state.maxLevel),
- style = MaterialTheme.typography.headlineSmall,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(start = 8.dp),
- )
- RangeSlider(
- value = state.minLevel.toFloat()..state.maxLevel.toFloat(),
- onValueChange = { range ->
- val next = DIYState.BatteryLevel(range.start.roundToInt(), range.endInclusive.roundToInt())
- if (next != state) {
- HapticUtil.performSliderHaptic(view)
- state = next
- onStateChange(next)
- }
- },
- valueRange = 0f..100f,
- steps = 99,
- )
- }
- }
-}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt
index e52f6031c..ebdabad9b 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/components/AutomationItem.kt
@@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -61,7 +62,9 @@ import androidx.compose.ui.unit.dp
import com.sameerasw.essentials.R
import com.sameerasw.essentials.domain.diy.Action
import com.sameerasw.essentials.domain.diy.Automation
+import com.sameerasw.essentials.domain.diy.DIYRuntimeStore
import com.sameerasw.essentials.domain.diy.Trigger
+import com.sameerasw.essentials.domain.diy.systemMetricText
import com.sameerasw.essentials.domain.diy.State as DIYState
import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu
import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem
@@ -86,6 +89,7 @@ fun AutomationItem(
) {
val view = LocalView.current
var showMenu by remember { mutableStateOf(false) }
+ val pausedIds by DIYRuntimeStore.pausedIds.collectAsState()
Card(
colors =
@@ -296,9 +300,16 @@ fun AutomationItem(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
- automationSummary(automation)?.let { summary ->
+ val isPaused = !automation.isEnabled && automation.id in pausedIds
+ val summary =
+ if (isPaused) {
+ stringResource(R.string.diy_paused_loop)
+ } else {
+ automationSummary(automation)
+ }
+ summary?.let {
Text(
- text = summary,
+ text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f),
maxLines = 2,
@@ -450,6 +461,7 @@ private fun automationSummary(automation: Automation): String? {
trigger.appName.ifBlank { null },
trigger.textContains.ifBlank { null }?.let { "\"$it\"" },
).joinToString(" · ").ifBlank { null }
+ is Trigger.SystemMetric -> systemMetricText(context, trigger.metric, trigger.comparison)
else -> null
}
@@ -458,6 +470,7 @@ private fun automationSummary(automation: Automation): String? {
is DIYState.TimePeriod ->
"${time(state.startHour, state.startMinute)} – ${time(state.endHour, state.endMinute)} · ${days(state.days)}"
is DIYState.BatteryLevel -> "${state.minLevel}% – ${state.maxLevel}%"
+ is DIYState.SystemMetric -> systemMetricText(context, state.metric, state.comparison)
is DIYState.CalendarEvent ->
if (state.calendarIds.isEmpty()) {
allCalendars
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SystemMetricSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SystemMetricSheet.kt
new file mode 100644
index 000000000..e90ca50c3
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SystemMetricSheet.kt
@@ -0,0 +1,408 @@
+/*
+ * Copyright (c) 2026 sameerasw.com
+ * License: MIT License
+ *
+ * Feature Module: Core UI Sheets
+ * File: SystemMetricSheet.kt
+ * Description: Bottom sheet editor for system metric triggers and states.
+ */
+
+package com.sameerasw.essentials.ui.core.sheets
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.BatteryManager
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RangeSlider
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.domain.diy.Comparison
+import com.sameerasw.essentials.domain.diy.Metric
+import com.sameerasw.essentials.domain.diy.Trigger
+import com.sameerasw.essentials.ui.components.CategoryExpandableSection
+import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer
+import com.sameerasw.essentials.utils.HapticUtil
+import kotlin.math.roundToInt
+import com.sameerasw.essentials.domain.diy.State as DIYState
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun SystemMetricSheet(
+ initialTrigger: Trigger.SystemMetric?,
+ initialState: DIYState.SystemMetric?,
+ onDismiss: () -> Unit,
+ onTriggerChange: (Trigger.SystemMetric) -> Unit,
+ onStateChange: (DIYState.SystemMetric) -> Unit,
+) {
+ val view = LocalView.current
+ val context = LocalContext.current
+ val isTrigger = initialTrigger != null
+
+ val metric = initialTrigger?.metric ?: initialState?.metric ?: Metric.BATTERY_LEVEL
+ var comparison by remember {
+ mutableStateOf(initialTrigger?.comparison ?: initialState?.comparison ?: Comparison())
+ }
+ var cooldownSeconds by remember {
+ mutableIntStateOf(initialTrigger?.cooldownSeconds ?: Trigger.DEFAULT_VALUE_COOLDOWN_SECONDS)
+ }
+ var advancedExpanded by remember { mutableStateOf(false) }
+
+ fun notifyChange() {
+ if (isTrigger) {
+ onTriggerChange(
+ Trigger.SystemMetric(
+ metric = metric,
+ comparison = comparison,
+ cooldownSeconds = cooldownSeconds,
+ ),
+ )
+ } else {
+ onStateChange(
+ DIYState.SystemMetric(
+ metric = metric,
+ comparison = comparison,
+ ),
+ )
+ }
+ }
+
+ var currentBatteryPercent by remember {
+ val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
+ val batteryStatus = context.registerReceiver(null, filter)
+ val level = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
+ val scale = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100
+ val p = if (level >= 0 && scale > 0) level * 100 / scale else null
+ mutableStateOf(p)
+ }
+
+ DisposableEffect(context) {
+ val receiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(
+ c: Context,
+ intent: Intent,
+ ) {
+ if (intent.action == Intent.ACTION_BATTERY_CHANGED) {
+ val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
+ val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
+ if (level >= 0 && scale > 0) {
+ currentBatteryPercent = level * 100 / scale
+ }
+ }
+ }
+ }
+ val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
+ context.registerReceiver(receiver, filter)
+ onDispose {
+ try {
+ context.unregisterReceiver(receiver)
+ } catch (e: Exception) {
+ // Ignore if not registered
+ }
+ }
+ }
+
+ EssentialsBottomSheet(onDismissRequest = onDismiss) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 32.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = stringResource(metric.title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+
+ val bigText =
+ if (comparison.operator.isRange) {
+ stringResource(
+ R.string.diy_battery_level_range,
+ comparison.value.roundToInt(),
+ (comparison.upper ?: comparison.value).roundToInt(),
+ )
+ } else {
+ stringResource(R.string.diy_metric_percent, comparison.value.roundToInt())
+ }
+ Text(
+ text = bigText,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+
+ val batteryLevelValue = currentBatteryPercent
+ if (batteryLevelValue != null) {
+ Text(
+ text =
+ stringResource(
+ R.string.diy_metric_now,
+ stringResource(R.string.diy_metric_percent, batteryLevelValue),
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+
+ val operators =
+ if (isTrigger) {
+ Comparison.Operator.entries
+ } else {
+ Comparison.Operator.entries.filter { it.forStates }
+ }
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ operators.forEach { op ->
+ FilterChip(
+ selected = comparison.operator == op,
+ onClick = {
+ if (comparison.operator != op) {
+ HapticUtil.performVirtualKeyHaptic(view)
+ val newUpper =
+ if (op.isRange) {
+ comparison.upper ?: minOf(comparison.value + 20.0, metric.max)
+ } else {
+ comparison.upper
+ }
+ val newValue =
+ if (op.isDelta && comparison.value < metric.step) {
+ metric.step
+ } else {
+ comparison.value
+ }
+ comparison = comparison.copy(operator = op, upper = newUpper, value = newValue)
+ notifyChange()
+ }
+ },
+ label = { Text(stringResource(operatorLabel(op))) },
+ )
+ }
+ }
+
+ if (comparison.operator.isRange) {
+ val start = comparison.value.toFloat()
+ val end = (comparison.upper ?: comparison.value).toFloat()
+ val steps = ((metric.max - metric.min) / metric.step).roundToInt() - 1
+ RangeSlider(
+ value = start..end,
+ onValueChange = { range ->
+ val newStart =
+ range.start
+ .toDouble()
+ .roundToInt()
+ .toDouble()
+ val newEnd =
+ range.endInclusive
+ .toDouble()
+ .roundToInt()
+ .toDouble()
+ if (newStart != comparison.value || newEnd != comparison.upper) {
+ HapticUtil.performSliderHaptic(view)
+ comparison = comparison.copy(value = newStart, upper = newEnd)
+ notifyChange()
+ }
+ },
+ valueRange = metric.min.toFloat()..metric.max.toFloat(),
+ steps = steps.coerceAtLeast(0),
+ )
+ } else {
+ val minVal = if (comparison.operator.isDelta) metric.step else metric.min
+ val maxVal = metric.max
+ val steps = ((maxVal - minVal) / metric.step).roundToInt() - 1
+ Slider(
+ value = comparison.value.toFloat().coerceIn(minVal.toFloat(), maxVal.toFloat()),
+ onValueChange = { v ->
+ val newV = v.toDouble().roundToInt().toDouble()
+ if (newV != comparison.value) {
+ HapticUtil.performSliderHaptic(view)
+ comparison = comparison.copy(value = newV)
+ notifyChange()
+ }
+ },
+ valueRange = minVal.toFloat()..maxVal.toFloat(),
+ steps = steps.coerceAtLeast(0),
+ )
+ }
+
+ val showHold = !comparison.operator.isDelta
+ val showCooldown = isTrigger
+ val showHysteresis = !comparison.operator.isDelta && comparison.operator != Comparison.Operator.NEQ
+ val advancedCount = (if (showHold) 1 else 0) + (if (showCooldown) 1 else 0) + (if (showHysteresis) 1 else 0)
+
+ if (advancedCount > 0) {
+ CategoryExpandableSection(
+ title = stringResource(R.string.diy_metric_advanced),
+ itemCount = advancedCount,
+ isExpanded = advancedExpanded,
+ onToggleExpand = { advancedExpanded = !advancedExpanded },
+ ) {
+ RoundedCardContainer(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.padding(top = 8.dp),
+ ) {
+ if (showHold) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.diy_metric_hold),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ text =
+ if (comparison.holdSeconds == 0) {
+ stringResource(R.string.diy_metric_hold_off)
+ } else {
+ stringResource(R.string.diy_metric_seconds, comparison.holdSeconds)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Slider(
+ value = comparison.holdSeconds.toFloat(),
+ onValueChange = { v ->
+ val stepped = (v / 5f).roundToInt() * 5
+ if (stepped != comparison.holdSeconds) {
+ HapticUtil.performSliderHaptic(view)
+ comparison = comparison.copy(holdSeconds = stepped)
+ notifyChange()
+ }
+ },
+ valueRange = 0f..300f,
+ steps = 59,
+ )
+ }
+ }
+
+ if (showCooldown) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.diy_metric_cooldown),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ text = stringResource(R.string.diy_metric_seconds, cooldownSeconds),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Slider(
+ value = cooldownSeconds.toFloat(),
+ onValueChange = { v ->
+ val stepped = (v / 5f).roundToInt() * 5
+ val clamped = stepped.coerceIn(Trigger.MIN_VALUE_COOLDOWN_SECONDS, 600)
+ if (clamped != cooldownSeconds) {
+ HapticUtil.performSliderHaptic(view)
+ cooldownSeconds = clamped
+ notifyChange()
+ }
+ },
+ valueRange = Trigger.MIN_VALUE_COOLDOWN_SECONDS.toFloat()..600f,
+ steps = 118,
+ )
+ }
+ }
+
+ if (showHysteresis) {
+ val currentHysteresis = (comparison.hysteresis ?: metric.defaultHysteresis).roundToInt()
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.diy_metric_hysteresis),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ text = stringResource(R.string.diy_metric_percent, currentHysteresis),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Slider(
+ value = currentHysteresis.toFloat(),
+ onValueChange = { v ->
+ val stepped = v.roundToInt()
+ if (stepped != currentHysteresis) {
+ HapticUtil.performSliderHaptic(view)
+ comparison = comparison.copy(hysteresis = stepped.toDouble())
+ notifyChange()
+ }
+ },
+ valueRange = 0f..10f,
+ steps = 9,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun operatorLabel(op: Comparison.Operator): Int =
+ when (op) {
+ Comparison.Operator.GT -> R.string.diy_metric_op_gt
+ Comparison.Operator.GTE -> R.string.diy_metric_op_gte
+ Comparison.Operator.LT -> R.string.diy_metric_op_lt
+ Comparison.Operator.LTE -> R.string.diy_metric_op_lte
+ Comparison.Operator.EQ -> R.string.diy_metric_op_eq
+ Comparison.Operator.NEQ -> R.string.diy_metric_op_neq
+ Comparison.Operator.BETWEEN -> R.string.diy_metric_op_between
+ Comparison.Operator.OUTSIDE -> R.string.diy_metric_op_outside
+ Comparison.Operator.RISES_ABOVE -> R.string.diy_metric_op_rises_above
+ Comparison.Operator.FALLS_BELOW -> R.string.diy_metric_op_falls_below
+ Comparison.Operator.CHANGES_BY -> R.string.diy_metric_op_changes_by
+ Comparison.Operator.INCREASES_BY -> R.string.diy_metric_op_increases_by
+ Comparison.Operator.DECREASES_BY -> R.string.diy_metric_op_decreases_by
+ }
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AutomationFailureNotifier.kt b/app/src/main/java/com/sameerasw/essentials/utils/AutomationFailureNotifier.kt
index b7274ef4c..2cd909587 100644
--- a/app/src/main/java/com/sameerasw/essentials/utils/AutomationFailureNotifier.kt
+++ b/app/src/main/java/com/sameerasw/essentials/utils/AutomationFailureNotifier.kt
@@ -19,11 +19,29 @@ import androidx.core.app.NotificationCompat
import com.sameerasw.essentials.MainActivity
import com.sameerasw.essentials.R
import com.sameerasw.essentials.domain.diy.Action
+import com.sameerasw.essentials.domain.diy.Automation
+import com.sameerasw.essentials.domain.diy.conditionText
import com.sameerasw.essentials.domain.model.AppPermission
+import com.sameerasw.essentials.services.receivers.LoopBreakerReceiver
object AutomationFailureNotifier {
private const val CHANNEL_ID = "automations"
+ private fun createChannelIfNeeded(
+ context: Context,
+ manager: NotificationManager,
+ ) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ manager.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.automation_channel_name),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ )
+ }
+ }
+
fun missingPermissions(
context: Context,
action: Action,
@@ -54,15 +72,7 @@ object AutomationFailureNotifier {
) {
if (!PermissionUtils.isPostNotificationsEnabled(context)) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- manager.createNotificationChannel(
- NotificationChannel(
- CHANNEL_ID,
- context.getString(R.string.automation_channel_name),
- NotificationManager.IMPORTANCE_DEFAULT,
- ),
- )
- }
+ createChannelIfNeeded(context, manager)
val actionName = context.getString(action.title)
val openApp =
@@ -87,4 +97,53 @@ object AutomationFailureNotifier {
.build()
manager.notify(action::class.java.name.hashCode(), notification)
}
+
+ fun notifyLoopBreak(
+ context: Context,
+ automation: Automation,
+ ) {
+ if (!PermissionUtils.isPostNotificationsEnabled(context)) return
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ createChannelIfNeeded(context, manager)
+
+ val openApp =
+ PendingIntent.getActivity(
+ context,
+ 0,
+ Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val reenableIntent =
+ Intent(context, LoopBreakerReceiver::class.java).apply {
+ putExtra("automation_id", automation.id)
+ }
+ val reenablePendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ automation.id.hashCode(),
+ reenableIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val condText = conditionText(context, automation)
+ val text = context.getString(R.string.automation_paused_text, condText)
+ val notification =
+ NotificationCompat
+ .Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setContentTitle(context.getString(R.string.automation_paused_title))
+ .setContentText(text)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(text))
+ .setContentIntent(openApp)
+ .addAction(
+ 0,
+ context.getString(R.string.automation_paused_reenable),
+ reenablePendingIntent,
+ ).setAutoCancel(true)
+ .build()
+ manager.notify(automation.id.hashCode(), notification)
+ }
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e08d93c08..b3b99b88a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1442,6 +1442,42 @@
Screen On
Battery level
%1$d%% – %2$d%%
+ Battery level
+ %1$d%%
+ Now: %1$s
+ Above
+ At least
+ Below
+ At most
+ Equals
+ Not equal
+ Between
+ Outside
+ Rises above
+ Falls below
+ Changes by
+ Increases by
+ Decreases by
+ %1$s above %2$s
+ %1$s at least %2$s
+ %1$s below %2$s
+ %1$s at most %2$s
+ %1$s equals %2$s
+ %1$s not equal to %2$s
+ %1$s between %2$s and %3$s
+ %1$s outside %2$s and %3$s
+ %1$s rises above %2$s
+ %1$s falls below %2$s
+ %1$s changes by %2$s
+ %1$s increases by %2$s
+ %1$s decreases by %2$s
+ Advanced
+ Stay true for
+ Off
+ %1$d s
+ Wait before firing again
+ Margin before resetting
+ Paused: kept triggering itself
Power Saving
Vibrate
@@ -1615,6 +1651,9 @@
%1$s: %2$s
Missing permission: %1$s
Could not change: %1$s
+ Automation paused
+ %1$s kept triggering itself, so it was turned off.
+ Re-enable
App Updates
Notifications for new app updates
Update Available!
diff --git a/app/src/test/java/com/sameerasw/essentials/domain/SystemMetricTrackerTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/SystemMetricTrackerTest.kt
new file mode 100644
index 000000000..d8e7ff780
--- /dev/null
+++ b/app/src/test/java/com/sameerasw/essentials/domain/SystemMetricTrackerTest.kt
@@ -0,0 +1,846 @@
+/*
+ * Copyright (c) 2026 sameerasw.com
+ * License: MIT License
+ *
+ * Feature Module: Domain Layer Tests
+ * File: SystemMetricTrackerTest.kt
+ * Description: Unit tests for SystemMetricTracker, Comparison, and state lifecycle.
+ */
+
+package com.sameerasw.essentials.domain
+
+import com.sameerasw.essentials.domain.diy.Action
+import com.sameerasw.essentials.domain.diy.Automation
+import com.sameerasw.essentials.domain.diy.Comparison
+import com.sameerasw.essentials.domain.diy.Metric
+import com.sameerasw.essentials.domain.diy.SystemMetricTracker
+import com.sameerasw.essentials.domain.diy.Trigger
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import com.sameerasw.essentials.domain.diy.State as DIYState
+
+class SystemMetricTrackerTest {
+ private class FakeClock(
+ var timeMs: Long = 1000L,
+ ) {
+ fun advance(ms: Long) {
+ timeMs += ms
+ }
+ }
+
+ private class FakeScheduler(
+ private val clock: FakeClock,
+ ) : SystemMetricTracker.HoldScheduler {
+ data class Task(
+ val triggerTimeMs: Long,
+ val block: () -> Unit,
+ var cancelled: Boolean = false,
+ )
+
+ val tasks = mutableListOf()
+
+ override fun schedule(
+ delayMs: Long,
+ block: () -> Unit,
+ ): () -> Unit {
+ val task = Task(clock.timeMs + delayMs, block)
+ tasks.add(task)
+ return { task.cancelled = true }
+ }
+
+ fun advanceBy(delayMs: Long) {
+ val targetTime = clock.timeMs + delayMs
+ clock.timeMs = targetTime
+ val ready = tasks.filter { !it.cancelled && it.triggerTimeMs <= targetTime }.sortedBy { it.triggerTimeMs }
+ for (task in ready) {
+ if (!task.cancelled) {
+ task.cancelled = true
+ task.block()
+ }
+ }
+ }
+ }
+
+ private class FakeOpenStateStore(
+ initial: Set = emptySet(),
+ ) : SystemMetricTracker.OpenStateStore {
+ var ids: Set = initial
+
+ override fun load(): Set = ids
+
+ override fun save(ids: Set) {
+ this.ids = ids
+ }
+ }
+
+ private class FakeListener : SystemMetricTracker.Listener {
+ val fires = mutableListOf()
+ val enters = mutableListOf()
+ val exits = mutableListOf()
+ val loopBreaks = mutableListOf()
+
+ override fun onFire(automation: Automation) {
+ fires.add(automation)
+ }
+
+ override fun onEnter(automation: Automation) {
+ enters.add(automation)
+ }
+
+ override fun onExit(automation: Automation) {
+ exits.add(automation)
+ }
+
+ override fun onLoopBreak(automation: Automation) {
+ loopBreaks.add(automation)
+ }
+
+ fun clear() {
+ fires.clear()
+ enters.clear()
+ exits.clear()
+ loopBreaks.clear()
+ }
+ }
+
+ @Test
+ fun comparisonIsActiveForEveryOperator() {
+ // GT and RISES_ABOVE
+ val gt = Comparison(operator = Comparison.Operator.GT, value = 50.0, hysteresis = 2.0)
+ assertFalse(gt.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(gt.isActive(50.1, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(gt.isActive(48.1, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(gt.isActive(48.0, wasActive = true, Metric.BATTERY_LEVEL))
+
+ val risesAbove = Comparison(operator = Comparison.Operator.RISES_ABOVE, value = 50.0, hysteresis = 2.0)
+ assertFalse(risesAbove.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(risesAbove.isActive(50.1, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(risesAbove.isActive(48.1, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(risesAbove.isActive(48.0, wasActive = true, Metric.BATTERY_LEVEL))
+
+ // GTE
+ val gte = Comparison(operator = Comparison.Operator.GTE, value = 50.0, hysteresis = 2.0)
+ assertFalse(gte.isActive(49.9, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(gte.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(gte.isActive(48.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(gte.isActive(47.9, wasActive = true, Metric.BATTERY_LEVEL))
+
+ // LT and FALLS_BELOW
+ val lt = Comparison(operator = Comparison.Operator.LT, value = 20.0, hysteresis = 2.0)
+ assertFalse(lt.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(lt.isActive(19.9, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(lt.isActive(21.9, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(lt.isActive(22.0, wasActive = true, Metric.BATTERY_LEVEL))
+
+ val fallsBelow = Comparison(operator = Comparison.Operator.FALLS_BELOW, value = 20.0, hysteresis = 2.0)
+ assertFalse(fallsBelow.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(fallsBelow.isActive(19.9, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(fallsBelow.isActive(21.9, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(fallsBelow.isActive(22.0, wasActive = true, Metric.BATTERY_LEVEL))
+
+ // LTE
+ val lte = Comparison(operator = Comparison.Operator.LTE, value = 20.0, hysteresis = 2.0)
+ assertFalse(lte.isActive(20.1, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(lte.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(lte.isActive(22.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(lte.isActive(22.1, wasActive = true, Metric.BATTERY_LEVEL))
+
+ // EQ (step = 1.0, e = 0.5)
+ val eq = Comparison(operator = Comparison.Operator.EQ, value = 50.0, hysteresis = 2.0)
+ assertFalse(eq.isActive(49.4, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(eq.isActive(49.5, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(eq.isActive(50.5, wasActive = false, Metric.BATTERY_LEVEL))
+ assertFalse(eq.isActive(50.6, wasActive = false, Metric.BATTERY_LEVEL))
+ // while active e + h = 2.5
+ assertTrue(eq.isActive(47.5, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(eq.isActive(47.4, wasActive = true, Metric.BATTERY_LEVEL))
+ assertTrue(eq.isActive(52.5, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(eq.isActive(52.6, wasActive = true, Metric.BATTERY_LEVEL))
+
+ // NEQ (no hysteresis)
+ val neq = Comparison(operator = Comparison.Operator.NEQ, value = 50.0, hysteresis = 2.0)
+ assertFalse(neq.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(neq.isActive(50.6, wasActive = false, Metric.BATTERY_LEVEL))
+ assertFalse(neq.isActive(50.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertTrue(neq.isActive(50.6, wasActive = true, Metric.BATTERY_LEVEL))
+
+ // BETWEEN
+ val between = Comparison(operator = Comparison.Operator.BETWEEN, value = 20.0, upper = 80.0, hysteresis = 2.0)
+ assertFalse(between.isActive(19.9, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(between.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(between.isActive(80.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertFalse(between.isActive(80.1, wasActive = false, Metric.BATTERY_LEVEL))
+ // while active: a - h .. b + h -> 18.0 .. 82.0
+ assertTrue(between.isActive(18.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(between.isActive(17.9, wasActive = true, Metric.BATTERY_LEVEL))
+ assertTrue(between.isActive(82.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(between.isActive(82.1, wasActive = true, Metric.BATTERY_LEVEL))
+
+ // OUTSIDE: b - a > 2h
+ val outsideWide = Comparison(operator = Comparison.Operator.OUTSIDE, value = 20.0, upper = 80.0, hysteresis = 2.0)
+ assertTrue(outsideWide.isActive(19.9, wasActive = false, Metric.BATTERY_LEVEL))
+ assertFalse(outsideWide.isActive(20.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertFalse(outsideWide.isActive(80.0, wasActive = false, Metric.BATTERY_LEVEL))
+ assertTrue(outsideWide.isActive(80.1, wasActive = false, Metric.BATTERY_LEVEL))
+ // while active: v < a + h or v > b - h -> v < 22 or v > 78
+ assertTrue(outsideWide.isActive(21.9, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(outsideWide.isActive(22.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(outsideWide.isActive(78.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertTrue(outsideWide.isActive(78.1, wasActive = true, Metric.BATTERY_LEVEL))
+
+ // OUTSIDE: b - a <= 2h uses h = 0
+ val outsideNarrow = Comparison(operator = Comparison.Operator.OUTSIDE, value = 50.0, upper = 53.0, hysteresis = 2.0)
+ assertTrue(outsideNarrow.isActive(49.9, wasActive = false, Metric.BATTERY_LEVEL))
+ assertFalse(outsideNarrow.isActive(50.0, wasActive = false, Metric.BATTERY_LEVEL))
+ // while active with h = 0
+ assertTrue(outsideNarrow.isActive(49.9, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(outsideNarrow.isActive(50.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertFalse(outsideNarrow.isActive(53.0, wasActive = true, Metric.BATTERY_LEVEL))
+ assertTrue(outsideNarrow.isActive(53.1, wasActive = true, Metric.BATTERY_LEVEL))
+ }
+
+ @Test
+ fun deltaOperatorsTrackingAndReset() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ // CHANGES_BY
+ val changesByAuto =
+ Automation(
+ id = "c1",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.CHANGES_BY, value = 5.0),
+ cooldownSeconds = 5,
+ ),
+ )
+ tracker.updateAutomations(listOf(changesByAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline
+ assertEquals(0, listener.fires.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 53.0)
+ assertEquals(0, listener.fires.size)
+
+ clock.advance(10_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 55.0) // +5 from ref 50
+ assertEquals(1, listener.fires.size)
+
+ clock.advance(10_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // -5 from ref 55
+ assertEquals(2, listener.fires.size)
+
+ // INCREASES_BY: tracks minimum while not fired
+ val incByAuto =
+ Automation(
+ id = "inc1",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.INCREASES_BY, value = 10.0),
+ cooldownSeconds = 5,
+ ),
+ )
+ tracker.updateAutomations(listOf(incByAuto))
+ listener.clear()
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline
+ assertEquals(0, listener.fires.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 40.0) // drops to 40, ref becomes 40
+ assertEquals(0, listener.fires.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 48.0) // +8 from min 40
+ assertEquals(0, listener.fires.size)
+
+ clock.advance(10_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // +10 from min 40, fires!
+ assertEquals(1, listener.fires.size)
+
+ // DECREASES_BY: tracks maximum while not fired
+ val decByAuto =
+ Automation(
+ id = "dec1",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.DECREASES_BY, value = 10.0),
+ cooldownSeconds = 5,
+ ),
+ )
+ tracker.updateAutomations(listOf(decByAuto))
+ listener.clear()
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline
+ assertEquals(0, listener.fires.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 60.0) // rises to 60, ref becomes 60
+ assertEquals(0, listener.fires.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 52.0) // -8 from max 60
+ assertEquals(0, listener.fires.size)
+
+ clock.advance(10_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // -10 from max 60, fires!
+ assertEquals(1, listener.fires.size)
+ }
+
+ @Test
+ fun deDupeSameValueTwiceEvaluatesOnce() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val triggerAuto =
+ Automation(
+ id = "t1",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.CHANGES_BY, value = 5.0),
+ cooldownSeconds = 0,
+ ),
+ )
+ tracker.updateAutomations(listOf(triggerAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0)
+ tracker.onReading(Metric.BATTERY_LEVEL, 55.0)
+ assertEquals(1, listener.fires.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 55.0) // duplicate reading
+ assertEquals(1, listener.fires.size)
+ }
+
+ @Test
+ fun baselineBehaviorBothOrders() {
+ val triggerAuto =
+ Automation(
+ id = "t1",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0),
+ ),
+ )
+ val stateAuto =
+ Automation(
+ id = "s1",
+ type = Automation.Type.STATE,
+ state =
+ DIYState.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0),
+ ),
+ )
+
+ // Order 1: automations first, then reading
+ run {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ tracker.updateAutomations(listOf(triggerAuto, stateAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0)
+
+ assertEquals(0, listener.fires.size) // trigger never fires on baseline
+ assertEquals(1, listener.enters.size) // state enters on baseline
+ }
+
+ // Order 2: reading first, then automations
+ run {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0)
+ tracker.updateAutomations(listOf(triggerAuto, stateAuto))
+
+ assertEquals(0, listener.fires.size) // trigger never fires on baseline
+ assertEquals(1, listener.enters.size) // state enters on baseline
+ assertEquals(setOf("s1"), store.ids)
+ }
+ }
+
+ @Test
+ fun holdDelaysExecutionAndCanBeCancelled() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val stateAuto =
+ Automation(
+ id = "s1",
+ type = Automation.Type.STATE,
+ state =
+ DIYState.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0, holdSeconds = 5),
+ ),
+ )
+ tracker.updateAutomations(listOf(stateAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline inactive
+ assertEquals(0, listener.enters.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0) // active, schedules hold 5s
+ assertEquals(0, listener.enters.size)
+
+ scheduler.advanceBy(4_000)
+ assertEquals(0, listener.enters.size)
+
+ scheduler.advanceBy(1_000)
+ assertEquals(1, listener.enters.size) // 5s reached, enters
+
+ // Exit is immediate
+ tracker.onReading(Metric.BATTERY_LEVEL, 25.0)
+ assertEquals(1, listener.exits.size)
+
+ // False before hold cancels hold
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0) // becomes active again
+ scheduler.advanceBy(2_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 25.0) // goes false before 5s
+ scheduler.advanceBy(4_000)
+ assertEquals(1, listener.enters.size) // still 1 enter, hold was cancelled
+ }
+
+ @Test
+ fun cooldownSkipsIntermediateEdges() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val triggerAuto =
+ Automation(
+ id = "t1",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0, hysteresis = 2.0),
+ cooldownSeconds = 10,
+ ),
+ )
+ tracker.updateAutomations(listOf(triggerAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 25.0) // baseline inactive
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 20.0) // fires (edge 25 -> 20)
+ assertEquals(1, listener.fires.size)
+
+ // Re-arm: goes to 23 (inactive with hysteresis), then back to 20 inside cooldown (5s < 10s)
+ clock.advance(5_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 23.0)
+ tracker.onReading(Metric.BATTERY_LEVEL, 20.0)
+ assertEquals(1, listener.fires.size) // skipped due to cooldown
+
+ // Re-arm again after cooldown expires (15s > 10s)
+ clock.advance(10_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 23.0)
+ tracker.onReading(Metric.BATTERY_LEVEL, 20.0)
+ assertEquals(2, listener.fires.size) // allowed after cooldown
+ }
+
+ @Test
+ fun loopBreakerTripsOnSixthFireInWindow() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val triggerAuto =
+ Automation(
+ id = "loop1",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.CHANGES_BY, value = 1.0),
+ cooldownSeconds = 5,
+ ),
+ )
+ tracker.updateAutomations(listOf(triggerAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // baseline
+
+ // 5 fires in 60 s (each at +6s)
+ for (i in 1..5) {
+ clock.advance(6_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0 + i)
+ }
+ assertEquals(5, listener.fires.size)
+ assertEquals(0, listener.loopBreaks.size)
+
+ // 6th fire at t = 36s (within 60s window) -> trips!
+ clock.advance(6_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 57.0)
+ assertEquals(5, listener.fires.size) // not run
+ assertEquals(1, listener.loopBreaks.size) // onLoopBreak called once
+
+ // Tripped record ignores subsequent readings
+ clock.advance(6_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 60.0)
+ assertEquals(5, listener.fires.size)
+ assertEquals(1, listener.loopBreaks.size)
+
+ // Re-enable (removed and added again): counter starts at zero
+ tracker.updateAutomations(emptyList())
+ tracker.updateAutomations(listOf(triggerAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 65.0)
+ assertEquals(6, listener.fires.size) // runs again!
+ }
+
+ @Test
+ fun loopBreakerDropsTimestampsOlderThanWindow() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val triggerAuto =
+ Automation(
+ id = "loop1",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.CHANGES_BY, value = 1.0),
+ cooldownSeconds = 5,
+ ),
+ )
+ tracker.updateAutomations(listOf(triggerAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0)
+
+ // 5 fires spaced out: 0s, 10s, 20s, 30s, 40s
+ for (i in 1..5) {
+ clock.advance(10_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0 + i)
+ }
+ assertEquals(5, listener.fires.size)
+
+ // Advance to 75s (the first fire at 10s is older than 60s from 75s)
+ clock.advance(25_000)
+ tracker.onReading(Metric.BATTERY_LEVEL, 60.0)
+ assertEquals(6, listener.fires.size)
+ assertEquals(0, listener.loopBreaks.size)
+ }
+
+ @Test
+ fun closeGuaranteeTransitions() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val action1 = Action.ToggleFlashlight
+ val action2 = Action.SendNotification(notificationTitle = "Exit", body = "Body")
+ val stateAuto =
+ Automation(
+ id = "s1",
+ type = Automation.Type.STATE,
+ state =
+ DIYState.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0),
+ ),
+ exitActions = listOf(action1),
+ )
+
+ // 1. Enter state
+ tracker.updateAutomations(listOf(stateAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0)
+ assertEquals(1, listener.enters.size)
+ assertEquals(setOf("s1"), store.ids)
+
+ // 2. Value exit
+ tracker.onReading(Metric.BATTERY_LEVEL, 25.0)
+ assertEquals(1, listener.exits.size)
+ assertEquals(emptySet(), store.ids)
+
+ // 3. Enter again, then remove while open -> exit with old actions
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0)
+ assertEquals(2, listener.enters.size)
+ assertEquals(setOf("s1"), store.ids)
+
+ tracker.updateAutomations(emptyList()) // removed/disabled
+ assertEquals(2, listener.exits.size)
+ assertEquals(listOf(action1), listener.exits.last().exitActionList)
+ assertEquals(emptySet(), store.ids)
+
+ // 4. Condition edited -> exit then re-baseline
+ tracker.updateAutomations(listOf(stateAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0) // open again
+ assertEquals(3, listener.enters.size)
+
+ val editedCondition =
+ stateAuto.copy(
+ state =
+ DIYState.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.GTE, value = 80.0),
+ ),
+ )
+ tracker.updateAutomations(listOf(editedCondition))
+ assertEquals(3, listener.exits.size) // exited old condition
+ assertEquals(emptySet(), store.ids) // 15 is not >= 80, so new condition is not entered
+
+ // 5. Actions-only edit -> no exit, later exit uses new actions
+ val reentered = stateAuto.copy(exitActions = listOf(action1))
+ tracker.updateAutomations(listOf(reentered)) // 15 <= 20, enters
+ assertEquals(4, listener.enters.size)
+ val initialExitCount = listener.exits.size
+
+ val editedActionsOnly = reentered.copy(exitActions = listOf(action2))
+ tracker.updateAutomations(listOf(editedActionsOnly))
+ assertEquals(initialExitCount, listener.exits.size) // no exit on actions edit
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 25.0)
+ assertEquals(initialExitCount + 1, listener.exits.size)
+ assertEquals(listOf(action2), listener.exits.last().exitActionList)
+
+ // 6. closeAll exits all and saves empty set
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0)
+ assertEquals(5, listener.enters.size)
+ assertEquals(setOf("s1"), store.ids)
+
+ tracker.closeAll()
+ assertEquals(initialExitCount + 2, listener.exits.size)
+ assertEquals(emptySet(), store.ids)
+
+ // 7. Exit never called for a never-entered state
+ val neverEntered =
+ Automation(
+ id = "s_never",
+ type = Automation.Type.STATE,
+ state =
+ DIYState.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.GTE, value = 90.0),
+ ),
+ )
+ tracker.updateAutomations(listOf(neverEntered))
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // not >= 90
+ val exitCountBeforeRemove = listener.exits.size
+ tracker.updateAutomations(emptyList()) // removed
+ assertEquals(exitCountBeforeRemove, listener.exits.size) // no exit called
+
+ // 8. Same list twice -> no callbacks
+ tracker.updateAutomations(listOf(stateAuto))
+ val currentEnters = listener.enters.size
+ val currentExits = listener.exits.size
+ tracker.updateAutomations(listOf(stateAuto))
+ assertEquals(currentEnters, listener.enters.size)
+ assertEquals(currentExits, listener.exits.size)
+ }
+
+ @Test
+ fun restoredOpenStateHandling() {
+ val stateAuto =
+ Automation(
+ id = "s_restored",
+ type = Automation.Type.STATE,
+ state =
+ DIYState.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0),
+ ),
+ )
+
+ // Case A: restored open + still true -> no enter callback, marked open
+ run {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore(setOf("s_restored"))
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ tracker.updateAutomations(listOf(stateAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 15.0) // still true
+
+ assertEquals(0, listener.enters.size) // no enter!
+ assertEquals(0, listener.exits.size)
+ assertEquals(setOf("s_restored"), store.ids)
+ }
+
+ // Case B: restored open + now false -> exit callback
+ run {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore(setOf("s_restored"))
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ tracker.updateAutomations(listOf(stateAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // false!
+
+ assertEquals(0, listener.enters.size)
+ assertEquals(1, listener.exits.size) // exit called!
+ assertEquals(emptySet(), store.ids)
+ }
+
+ // Case C: restored but missing from first update -> dropped, no callbacks
+ run {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore(setOf("s_restored"))
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ tracker.updateAutomations(emptyList()) // missing from list
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0)
+
+ assertEquals(0, listener.enters.size)
+ assertEquals(0, listener.exits.size)
+ assertEquals(emptySet(), store.ids)
+ }
+
+ // Restored open unbaselined, removed from updateAutomations -> exit callback
+ run {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore(setOf("s_restored"))
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ tracker.updateAutomations(listOf(stateAuto))
+ tracker.updateAutomations(emptyList())
+
+ assertEquals(0, listener.enters.size)
+ assertEquals(1, listener.exits.size)
+ assertEquals(emptySet(), store.ids)
+ }
+
+ // Restored open unbaselined, closeAll -> exit callback
+ run {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore(setOf("s_restored"))
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ tracker.updateAutomations(listOf(stateAuto))
+ tracker.closeAll()
+
+ assertEquals(0, listener.enters.size)
+ assertEquals(1, listener.exits.size)
+ assertEquals(emptySet(), store.ids)
+ }
+ }
+
+ @Test
+ fun firstFireAllowedWhenClockStartsAtZero() {
+ val clock = FakeClock(timeMs = 0L)
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val triggerAuto =
+ Automation(
+ id = "t_zero",
+ type = Automation.Type.TRIGGER,
+ trigger =
+ Trigger.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison = Comparison(operator = Comparison.Operator.LTE, value = 20.0),
+ cooldownSeconds = 60,
+ ),
+ )
+ tracker.updateAutomations(listOf(triggerAuto))
+ tracker.onReading(Metric.BATTERY_LEVEL, 25.0)
+ tracker.onReading(Metric.BATTERY_LEVEL, 20.0)
+ assertEquals(1, listener.fires.size)
+ }
+
+ @Test
+ fun stateBatteryLevelMappedThroughTracker() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val batteryLevelState =
+ Automation(
+ id = "bl1",
+ type = Automation.Type.STATE,
+ state = DIYState.BatteryLevel(minLevel = 20, maxLevel = 80),
+ )
+
+ tracker.updateAutomations(listOf(batteryLevelState))
+ tracker.onReading(Metric.BATTERY_LEVEL, 10.0) // out of range
+ assertEquals(0, listener.enters.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 50.0) // in range: enters!
+ assertEquals(1, listener.enters.size)
+ assertEquals(setOf("bl1"), store.ids)
+
+ tracker.updateAutomations(emptyList()) // removed: exits!
+ assertEquals(1, listener.exits.size)
+ assertEquals(emptySet(), store.ids)
+ }
+
+ @Test
+ fun legacyBatteryLevelStateTransitionToSystemMetricKeepsStateOpen() {
+ val clock = FakeClock()
+ val scheduler = FakeScheduler(clock)
+ val store = FakeOpenStateStore()
+ val listener = FakeListener()
+ val tracker = SystemMetricTracker(clock::timeMs, scheduler, store, listener)
+
+ val legacy =
+ Automation(
+ id = "bl_convert",
+ type = Automation.Type.STATE,
+ state = DIYState.BatteryLevel(minLevel = 80, maxLevel = 100),
+ )
+
+ tracker.updateAutomations(listOf(legacy))
+ tracker.onReading(Metric.BATTERY_LEVEL, 85.0)
+ assertEquals(1, listener.enters.size)
+ assertEquals(0, listener.exits.size)
+ assertEquals(setOf("bl_convert"), store.ids)
+
+ val converted =
+ Automation(
+ id = "bl_convert",
+ type = Automation.Type.STATE,
+ state =
+ DIYState.SystemMetric(
+ metric = Metric.BATTERY_LEVEL,
+ comparison =
+ Comparison(
+ operator = Comparison.Operator.BETWEEN,
+ value = 80.0,
+ upper = 100.0,
+ hysteresis = 0.0,
+ ),
+ ),
+ )
+
+ tracker.updateAutomations(listOf(converted))
+ assertEquals(1, listener.enters.size)
+ assertEquals(0, listener.exits.size)
+
+ tracker.onReading(Metric.BATTERY_LEVEL, 70.0)
+ assertEquals(1, listener.enters.size)
+ assertEquals(1, listener.exits.size)
+ assertEquals(emptySet(), store.ids)
+ }
+}