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) {
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) {
@@ -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))
val scheduled = LocalDateTime.of(now.toLocalDate(), time) .map { date -> LocalDateTime.of(date, time) }
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()
} 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" }