From 73a18b0235a1a9c26a2448806f9272241b1284ab Mon Sep 17 00:00:00 2001 From: yuga-hashimoto Date: Wed, 22 Apr 2026 13:12:36 +0900 Subject: [PATCH] feat(proactive): ThermalWarningRule surfaces why wake-word paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Priority 3 — UX polish VoiceService already silently pauses wake-word on WARM/HOT thermal states (via SaverStateProvider). Users had no way to tell *why* background voice listening just stopped — looked like the device broke. Now a proactive card explains the situation and suggests moving the tablet somewhere cooler. - WARM → NORMAL priority nudge ("I'll ease off background work...") - HOT → HIGH priority nudge ("wake-word listening is paused...") - Stable ids within a bucket so SuggestionState doesn't re-surface the card on every poll. - expiresAtMs = now + 5 min so the card retires shortly after the temperature recovers. Wired into SuggestionEngine alongside LowBatteryRule, ChargingCompleteRule, ForgotLightsAtBedtimeRule. --- .../assistant/proactive/ThermalWarningRule.kt | 52 ++++++++++++++++++ .../java/com/opendash/app/di/DeviceModule.kt | 4 ++ .../proactive/ThermalWarningRuleTest.kt | 54 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 app/src/main/java/com/opendash/app/assistant/proactive/ThermalWarningRule.kt create mode 100644 app/src/test/java/com/opendash/app/assistant/proactive/ThermalWarningRuleTest.kt diff --git a/app/src/main/java/com/opendash/app/assistant/proactive/ThermalWarningRule.kt b/app/src/main/java/com/opendash/app/assistant/proactive/ThermalWarningRule.kt new file mode 100644 index 00000000..abf3d8bf --- /dev/null +++ b/app/src/main/java/com/opendash/app/assistant/proactive/ThermalWarningRule.kt @@ -0,0 +1,52 @@ +package com.opendash.app.assistant.proactive + +import com.opendash.app.util.ThermalLevel + +/** + * Proactive nudge when the tablet is running hot enough that the OS + * has flagged thermal throttling. Pairs with the existing battery-saver + * gating in VoiceService (which silently pauses wake-word on WARM/HOT): + * this rule tells the user *why* background work just paused so they + * can move the device somewhere cooler instead of assuming it's broken. + * + * Dedupe: id stable within a bucket so SuggestionState does not re-surface + * the card on every poll while the temperature lingers. + * + * Levels come from [com.opendash.app.util.ThermalMonitor]. The rule is + * supplier-based so tests can feed synthetic samples without constructing + * the real monitor (which registers an Android PowerManager listener + * unavailable on pure JVM). + */ +class ThermalWarningRule( + private val levelSupplier: () -> ThermalLevel, +) : SuggestionRule { + + override suspend fun evaluate(context: ProactiveContext): Suggestion? { + return when (levelSupplier()) { + ThermalLevel.NORMAL -> null + ThermalLevel.WARM -> Suggestion( + id = "thermal_warm", + priority = Suggestion.Priority.NORMAL, + message = "The tablet is warming up. I'll ease off background work " + + "until it cools. Moving it out of direct sunlight can help.", + suggestedAction = null, + expiresAtMs = context.nowMs + EXPIRY_WINDOW_MS, + ) + ThermalLevel.HOT -> Suggestion( + id = "thermal_hot", + priority = Suggestion.Priority.HIGH, + message = "The tablet is overheating — wake-word listening is paused. " + + "Please give it a break somewhere cooler before it throttles further.", + suggestedAction = null, + expiresAtMs = context.nowMs + EXPIRY_WINDOW_MS, + ) + } + } + + private companion object { + // Five minutes — short enough that the card disappears promptly + // once thermal returns to NORMAL; long enough that we don't spam + // the user with repeat cards while they cool the device down. + const val EXPIRY_WINDOW_MS = 5L * 60 * 1_000 + } +} diff --git a/app/src/main/java/com/opendash/app/di/DeviceModule.kt b/app/src/main/java/com/opendash/app/di/DeviceModule.kt index de805a2d..f91ecf34 100644 --- a/app/src/main/java/com/opendash/app/di/DeviceModule.kt +++ b/app/src/main/java/com/opendash/app/di/DeviceModule.kt @@ -264,6 +264,7 @@ object DeviceModule { peerLivenessTracker: com.opendash.app.multiroom.PeerLivenessTracker, deviceManager: DeviceManager, batteryMonitor: com.opendash.app.util.BatteryMonitor, + thermalMonitor: com.opendash.app.util.ThermalMonitor, ): com.opendash.app.assistant.proactive.SuggestionEngine = com.opendash.app.assistant.proactive.SuggestionEngine( rules = listOf( @@ -277,6 +278,9 @@ object DeviceModule { com.opendash.app.assistant.proactive.LowBatteryRule( statusSupplier = { batteryMonitor.status.value } ), + com.opendash.app.assistant.proactive.ThermalWarningRule( + levelSupplier = { thermalMonitor.status.value } + ), com.opendash.app.assistant.proactive.ForgotLightsAtBedtimeRule( devicesSupplier = { deviceManager.devices.value.values } ), diff --git a/app/src/test/java/com/opendash/app/assistant/proactive/ThermalWarningRuleTest.kt b/app/src/test/java/com/opendash/app/assistant/proactive/ThermalWarningRuleTest.kt new file mode 100644 index 00000000..116ad5ae --- /dev/null +++ b/app/src/test/java/com/opendash/app/assistant/proactive/ThermalWarningRuleTest.kt @@ -0,0 +1,54 @@ +package com.opendash.app.assistant.proactive + +import com.google.common.truth.Truth.assertThat +import com.opendash.app.util.ThermalLevel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class ThermalWarningRuleTest { + + private val ctx = ProactiveContext( + nowMs = 1_700_000_000_000L, + hourOfDay = 14, + dayOfWeek = java.util.Calendar.WEDNESDAY, + ) + + private fun rule(level: ThermalLevel) = + ThermalWarningRule(levelSupplier = { level }) + + @Test + fun `NORMAL thermal state emits nothing`() = runTest { + val s = rule(ThermalLevel.NORMAL).evaluate(ctx) + assertThat(s).isNull() + } + + @Test + fun `WARM emits NORMAL priority nudge`() = runTest { + val s = rule(ThermalLevel.WARM).evaluate(ctx) + assertThat(s).isNotNull() + assertThat(s!!.priority).isEqualTo(Suggestion.Priority.NORMAL) + assertThat(s.id).isEqualTo("thermal_warm") + } + + @Test + fun `HOT emits HIGH priority urgent nudge`() = runTest { + val s = rule(ThermalLevel.HOT).evaluate(ctx) + assertThat(s).isNotNull() + assertThat(s!!.priority).isEqualTo(Suggestion.Priority.HIGH) + assertThat(s.id).isEqualTo("thermal_hot") + } + + @Test + fun `id stable across evaluations within same bucket`() = runTest { + val a = rule(ThermalLevel.WARM).evaluate(ctx) + val b = rule(ThermalLevel.WARM).evaluate(ctx) + assertThat(a!!.id).isEqualTo(b!!.id) + } + + @Test + fun `expiresAt is set so SuggestionState can retire the card`() = runTest { + val s = rule(ThermalLevel.WARM).evaluate(ctx) + assertThat(s!!.expiresAtMs).isNotNull() + assertThat(s.expiresAtMs!!).isGreaterThan(ctx.nowMs) + } +}