Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 4 additions & 0 deletions app/src/main/java/com/opendash/app/di/DeviceModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 }
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading