feat(scene): complete automation runtime #11
@@ -40,6 +40,8 @@ dependencies {
|
||||
kapt(libs.hilt.compiler)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
|
|
||||
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) {
|
||||
|
pr-agent-bot
commented
后台轮询 分钟级轮询启动后只依赖应用级协程作用域,没有与 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) {
|
||||
|
pr-agent-bot
commented
执行中断
**执行中断**
`collectLatest` 将前台状态监听和场景评估放在同一个可取消块中。应用在场景执行期间进入后台时,`isForeground` 的变化会取消正在运行的 `engine.evaluate()`;此时触发状态或定时 occurrence 已经持久化,包含延迟或慢设备动作的场景可能只执行一部分,后续 worker 也不会重试该次触发。应只停止后续轮询,并允许当前评估完成。
<!-- pr-agent-dedup: bca7d5c309ee -->
<!-- pr-agent-key-issue-location: 3417d4aebe9e -->
|
||||
@@ -43,4 +51,12 @@ class AutomationBootstrapper(
|
||||
}
|
||||
|
pr-agent-bot
commented
重复调度 有效会话在前台时,每轮 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
|
||||
|
pr-agent-bot
commented
定时漏触发 时间触发仅接受最多晚到 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)
|
||||
|
pr-agent-bot
commented
跨日漏触发 时间触发器始终基于当前日期构造计划时间,因此午夜后执行时无法匹配前一天的计划。例如周一 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}")
|
||||
|
pr-agent-bot
commented
提前触发 当轮询发生在计划时间前不足一分钟时,负的 **提前触发**
当轮询发生在计划时间前不足一分钟时,负的 `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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
处理器缺失
这里只加入了 AndroidX Hilt Work 运行库,但
@HiltWorker还需要在声明 Worker 的模块中配置 AndroidX Hilt 编译器。否则不会生成并注册 Worker assisted factory,WorkManager 无法通过HiltWorkerFactory实例化AutomationPollWorker,后台自动化任务会在运行时失败;应增加对应的kapt或ksp编译器依赖。