feat(scene): complete automation runtime #11

Merged
KremeCN merged 9 commits from feat/scene-automation-runtime into main 2026-08-28 06:39:31 +08:00
4 changed files with 37 additions and 12 deletions
Showing only changes of commit 2624a8e1e5 - Show all commits
@@ -40,6 +40,8 @@ dependencies {
kapt(libs.hilt.compiler) kapt(libs.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)
Review

处理器缺失

这里只加入了 AndroidX Hilt Work 运行库,但 @HiltWorker 还需要在声明 Worker 的模块中配置 AndroidX Hilt 编译器。否则不会生成并注册 Worker assisted factory,WorkManager 无法通过 HiltWorkerFactory 实例化 AutomationPollWorker,后台自动化任务会在运行时失败;应增加对应的 kapt 或 ksp 编译器依赖。

**处理器缺失** 这里只加入了 AndroidX Hilt Work 运行库,但 `@HiltWorker` 还需要在声明 Worker 的模块中配置 AndroidX Hilt 编译器。否则不会生成并注册 Worker assisted factory,WorkManager 无法通过 `HiltWorkerFactory` 实例化 `AutomationPollWorker`,后台自动化任务会在运行时失败;应增加对应的 `kapt` 或 `ksp` 编译器依赖。 <!-- pr-agent-dedup: df5ab63303b0 --> <!-- pr-agent-key-issue-location: f5a5ce5a7abc -->
kapt(libs.androidx.hilt.compiler)
implementation(libs.androidx.lifecycle.process)
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.material3) implementation(libs.compose.material3)
@@ -1,14 +1,18 @@
package com.flagship.abox.manager.automation.runtime package com.flagship.abox.manager.automation.runtime
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionCoordinator
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.CancellationException
class AutomationBootstrapper( class AutomationBootstrapper(
private val profileStore: ProviderProfileStore, private val profileStore: ProviderProfileStore,
@@ -16,20 +20,24 @@ class AutomationBootstrapper(
private val scheduler: AutomationScheduler, private val scheduler: AutomationScheduler,
private val engine: AutomationEngine, private val engine: AutomationEngine,
private val scope: CoroutineScope, private val scope: CoroutineScope,
) { ) : DefaultLifecycleObserver {
private var previousProfileId: ProviderProfileId? = null private var previousProfileId: ProviderProfileId? = null
private val isForeground = MutableStateFlow(false)
fun start() { fun start() {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
scope.launch { scope.launch {
combine( combine(
profileStore.observeActiveProfile(), profileStore.observeActiveProfile(),
sessionCoordinator.observeActiveSession(), sessionCoordinator.observeActiveSession(),
) { active, session -> active to session }.collectLatest { (active, session) -> isForeground,
) { active, session, foreground -> Triple(active, session, foreground) }
.collectLatest { (active, session, foreground) ->
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) } previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
previousProfileId = active?.id previousProfileId = active?.id
if (active != null && session?.profileId == active.id) { if (active != null && session?.profileId == active.id) {
Outdated
Review

后台轮询

分钟级轮询启动后只依赖应用级协程作用域,没有与 Activity 或进程前台生命周期绑定。用户将应用切到后台,甚至仅由 WorkManager 拉起应用进程后,该循环仍会继续读取设备并评估场景,与 WorkManager 后台任务重复运行并产生持续的网络和电量开销;应在进程进入后台时停止该循环。

**后台轮询** 分钟级轮询启动后只依赖应用级协程作用域,没有与 Activity 或进程前台生命周期绑定。用户将应用切到后台,甚至仅由 WorkManager 拉起应用进程后,该循环仍会继续读取设备并评估场景,与 WorkManager 后台任务重复运行并产生持续的网络和电量开销;应在进程进入后台时停止该循环。 <!-- pr-agent-dedup: aa2efc5bb6a5 --> <!-- pr-agent-key-issue-location: ef1c3147c48c -->
scheduler.reconcile(active.id) scheduler.reconcile(active.id)
while (true) { while (foreground) {
try { try {
engine.evaluate(active.id) engine.evaluate(active.id)
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
Outdated
Review

执行中断

collectLatest 将前台状态监听和场景评估放在同一个可取消块中。应用在场景执行期间进入后台时,isForeground 的变化会取消正在运行的 engine.evaluate();此时触发状态或定时 occurrence 已经持久化,包含延迟或慢设备动作的场景可能只执行一部分,后续 worker 也不会重试该次触发。应只停止后续轮询,并允许当前评估完成。

**执行中断** `collectLatest` 将前台状态监听和场景评估放在同一个可取消块中。应用在场景执行期间进入后台时,`isForeground` 的变化会取消正在运行的 `engine.evaluate()`;此时触发状态或定时 occurrence 已经持久化,包含延迟或慢设备动作的场景可能只执行一部分,后续 worker 也不会重试该次触发。应只停止后续轮询,并允许当前评估完成。 <!-- pr-agent-dedup: bca7d5c309ee --> <!-- pr-agent-key-issue-location: 3417d4aebe9e -->
@@ -43,4 +51,12 @@ class AutomationBootstrapper(
} }
Review

重复调度

有效会话在前台时,每轮 60 秒轮询都会执行完整的调度协调。其下游会更新周期任务、取消全部时间触发任务并逐个重建,即使场景没有任何变化;场景或时间触发器较多时会持续产生 WorkManager 数据库写入和任务取消,并干扰临近执行时间的任务。调度协调应仅由配置、资料或会话变化触发,轮询循环只需执行引擎评估。

**重复调度** 有效会话在前台时,每轮 60 秒轮询都会执行完整的调度协调。其下游会更新周期任务、取消全部时间触发任务并逐个重建,即使场景没有任何变化;场景或时间触发器较多时会持续产生 WorkManager 数据库写入和任务取消,并干扰临近执行时间的任务。调度协调应仅由配置、资料或会话变化触发,轮询循环只需执行引擎评估。 <!-- pr-agent-dedup: 49f38f37f27d --> <!-- pr-agent-key-issue-location: 228b2c82db3e -->
} }
} }
override fun onStart(owner: LifecycleOwner) {
isForeground.value = true
}
override fun onStop(owner: LifecycleOwner) {
isForeground.value = false
}
} }
@@ -65,12 +65,16 @@ class SceneTriggerEvaluator {
private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) { private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) {
is SceneTrigger.Time -> { is SceneTrigger.Time -> {
val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time")) val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time"))
val dayMatches = trigger.daysOfWeek.isEmpty() || now.dayOfWeek.value in trigger.daysOfWeek val scheduled = listOf(now.toLocalDate(), now.toLocalDate().minusDays(1))
Outdated
Review

定时漏触发

时间触发仅接受最多晚到 20 分钟,但 WorkManager 的一次性和周期任务在 Doze、后台限制或系统繁忙时可能被推迟更久。任务最终运行时该 occurrence 会被判定为不匹配,并直接安排下一天或下一周,导致本次触发永久丢失。应持久化或传递预定 occurrence,避免以固定宽限窗口判断任务是否仍有效。

**定时漏触发** 时间触发仅接受最多晚到 20 分钟,但 WorkManager 的一次性和周期任务在 Doze、后台限制或系统繁忙时可能被推迟更久。任务最终运行时该 occurrence 会被判定为不匹配,并直接安排下一天或下一周,导致本次触发永久丢失。应持久化或传递预定 occurrence,避免以固定宽限窗口判断任务是否仍有效。 <!-- pr-agent-dedup: e523193f135d --> <!-- pr-agent-key-issue-location: 83ed580ab3fd -->
val scheduled = LocalDateTime.of(now.toLocalDate(), time) .map { date -> LocalDateTime.of(date, time) }
Outdated
Review

跨日漏触发

时间触发器始终基于当前日期构造计划时间,因此午夜后执行时无法匹配前一天的计划。例如周一 23:50 的任务被 WorkManager 延迟到周二 00:05 后,计划时间会被计算为周二 23:50,并且星期也按周二判断,导致本应处于宽限窗口内的任务永久漏执行。

**跨日漏触发** 时间触发器始终基于当前日期构造计划时间,因此午夜后执行时无法匹配前一天的计划。例如周一 23:50 的任务被 WorkManager 延迟到周二 00:05 后,计划时间会被计算为周二 23:50,并且星期也按周二判断,导致本应处于宽限窗口内的任务永久漏执行。 <!-- pr-agent-dedup: 24d013e675d0 --> <!-- pr-agent-key-issue-location: 9f6d81f0caab -->
val lateness = Duration.between(scheduled, now).toMinutes() .firstOrNull { candidate ->
if (dayMatches && lateness in 0..20) { val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}") val lateness = Duration.between(candidate, now).toMinutes()
Outdated
Review

提前触发

当轮询发生在计划时间前不足一分钟时,负的 Duration 经 toMinutes() 向零截断为 0,仍会通过时间窗口判断。例如 09:59:30 会提前执行 10:00 的规则并占用该 occurrence,真正到点时不再执行。应先排除负时长或改用秒级比较。

**提前触发** 当轮询发生在计划时间前不足一分钟时,负的 `Duration` 经 `toMinutes()` 向零截断为 0,仍会通过时间窗口判断。例如 09:59:30 会提前执行 10:00 的规则并占用该 occurrence,真正到点时不再执行。应先排除负时长或改用秒级比较。 <!-- pr-agent-dedup: 3629966bd0b7 --> <!-- pr-agent-key-issue-location: d1ec98b89700 -->
} else null dayMatches && lateness in 0..TIME_TRIGGER_GRACE_MINUTES
}
scheduled?.let { occurrence ->
TriggerEvaluation.Matched("${occurrence.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
}
} }
is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) { is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) {
it.temperature it.temperature
@@ -134,6 +138,7 @@ class SceneTriggerEvaluator {
private companion object { private companion object {
const val TEMPERATURE_HYSTERESIS = 0.5 const val TEMPERATURE_HYSTERESIS = 0.5
const val HUMIDITY_HYSTERESIS = 2.0 const val HUMIDITY_HYSTERESIS = 2.0
const val TIME_TRIGGER_GRACE_MINUTES = 20L
} }
} }
+2
View File
@@ -28,7 +28,9 @@ androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigationCompose" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigationCompose" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigationCompose" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptiveNavigationSuite" } androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptiveNavigationSuite" }
androidx-window = { module = "androidx.window:window", version.ref = "window" } androidx-window = { module = "androidx.window:window", version.ref = "window" }