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)
implementation(libs.androidx.work.runtime.ktx)
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(libs.compose.ui)
implementation(libs.compose.material3)
@@ -1,14 +1,18 @@
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.SessionCoordinator
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.CancellationException
class AutomationBootstrapper(
private val profileStore: ProviderProfileStore,
@@ -16,20 +20,24 @@ class AutomationBootstrapper(
private val scheduler: AutomationScheduler,
private val engine: AutomationEngine,
private val scope: CoroutineScope,
) {
) : DefaultLifecycleObserver {
private var previousProfileId: ProviderProfileId? = null
private val isForeground = MutableStateFlow(false)
fun start() {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
scope.launch {
combine(
profileStore.observeActiveProfile(),
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 = 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)
while (true) {
while (foreground) {
try {
engine.evaluate(active.id)
} 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) {
is SceneTrigger.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
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)
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()
if (dayMatches && lateness in 0..20) {
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
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
val scheduled = listOf(now.toLocalDate(), now.toLocalDate().minusDays(1))
.map { date -> LocalDateTime.of(date, time) }
.firstOrNull { candidate ->
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
val lateness = Duration.between(candidate, now).toMinutes()
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) {
it.temperature
@@ -134,6 +138,7 @@ class SceneTriggerEvaluator {
private companion object {
const val TEMPERATURE_HYSTERESIS = 0.5
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-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-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigationCompose" }
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-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptiveNavigationSuite" }
androidx-window = { module = "androidx.window:window", version.ref = "window" }