feat(scene): complete automation runtime #11
@@ -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)
|
||||||
|
|
|||||||
|
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)
|
||||||
|
|||||||
+22
-6
@@ -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(
|
|||||||
}
|
}
|
||||||
|
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) {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user
处理器缺失
这里只加入了 AndroidX Hilt Work 运行库,但
@HiltWorker还需要在声明 Worker 的模块中配置 AndroidX Hilt 编译器。否则不会生成并注册 Worker assisted factory,WorkManager 无法通过HiltWorkerFactory实例化AutomationPollWorker,后台自动化任务会在运行时失败;应增加对应的kapt或ksp编译器依赖。