fix(scene): decouple automation execution scheduling

This commit is contained in:
2026-08-28 04:02:01 +08:00
parent b6cc083bc7
commit c4af3266be
3 changed files with 38 additions and 22 deletions
@@ -38,6 +38,7 @@ data class ScheduledAutomationOccurrence(
val sceneId: String, val sceneId: String,
val triggerId: String, val triggerId: String,
val occurrenceKey: String, val occurrenceKey: String,
val triggerDefinition: String,
val scheduledAtEpochMillis: Long, val scheduledAtEpochMillis: Long,
val expiresAtEpochMillis: Long, val expiresAtEpochMillis: Long,
) )
@@ -57,23 +58,37 @@ class DefaultAutomationEngine(
override suspend fun evaluate( override suspend fun evaluate(
profileId: ProviderProfileId, profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?, scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<Int> = evaluationMutex.withLock { ): DomainResult<Int> {
evaluateLocked(profileId, scheduledOccurrence) val scenesToExecute = when (val prepared = evaluationMutex.withLock { prepareExecutions(profileId, scheduledOccurrence) }) {
is DomainResult.Failure -> return prepared
is DomainResult.Success -> prepared.value
}
var executed = 0
for (scene in scenesToExecute) {
sceneExecutor.execute(
scene = scene,
executionId = SceneExecutionId("auto-${UUID.randomUUID()}"),
source = SceneExecutionSource.AUTOMATION,
).collect { update ->
if (update is com.flagship.abox.manager.domain.SceneExecutionUpdate.Completed) executed++
}
}
return DomainResult.Success(executed)
} }
private suspend fun evaluateLocked( private suspend fun prepareExecutions(
profileId: ProviderProfileId, profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?, scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<Int> { ): DomainResult<List<Scene>> {
val scenes = when (val result = sceneRepository.observeScenes(profileId).first()) { val scenes = when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> return result is DomainResult.Failure -> return result
is DomainResult.Success -> result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC } is DomainResult.Success -> result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }
} }
if (scenes.isEmpty()) return DomainResult.Success(0) if (scenes.isEmpty()) return DomainResult.Success(emptyList())
val snapshot = collectSnapshot(profileId, scenes) val snapshot = collectSnapshot(profileId, scenes)
val now = LocalDateTime.ofInstant(Instant.ofEpochMilli(snapshot.observedAtEpochMillis), ZoneId.systemDefault()) val now = LocalDateTime.ofInstant(Instant.ofEpochMilli(snapshot.observedAtEpochMillis), ZoneId.systemDefault())
var executed = 0 val executions = mutableListOf<Scene>()
for (scene in scenes) { for (scene in scenes) {
var newlyMatched = false var newlyMatched = false
val newlyMatchedStateTriggers = mutableListOf<SceneTrigger>() val newlyMatchedStateTriggers = mutableListOf<SceneTrigger>()
@@ -91,6 +106,7 @@ class DefaultAutomationEngine(
trigger is SceneTrigger.Time && trigger is SceneTrigger.Time &&
scheduledOccurrence?.sceneId == scene.id.value && scheduledOccurrence?.sceneId == scene.id.value &&
scheduledOccurrence.triggerId == trigger.id.value && scheduledOccurrence.triggerId == trigger.id.value &&
scheduledOccurrence.triggerDefinition == trigger.automationDefinition() &&
snapshot.observedAtEpochMillis in scheduledOccurrence.scheduledAtEpochMillis..scheduledOccurrence.expiresAtEpochMillis snapshot.observedAtEpochMillis in scheduledOccurrence.scheduledAtEpochMillis..scheduledOccurrence.expiresAtEpochMillis
) { ) {
TriggerEvaluation.Matched(scheduledOccurrence.occurrenceKey) TriggerEvaluation.Matched(scheduledOccurrence.occurrenceKey)
@@ -143,16 +159,10 @@ class DefaultAutomationEngine(
if (executionCount == 0) continue if (executionCount == 0) continue
repeat(executionCount) { repeat(executionCount) {
sceneExecutor.execute( executions += scene
scene = scene,
executionId = SceneExecutionId("auto-${UUID.randomUUID()}"),
source = SceneExecutionSource.AUTOMATION,
).collect { update ->
if (update is com.flagship.abox.manager.domain.SceneExecutionUpdate.Completed) executed++
}
} }
} }
return DomainResult.Success(executed) return DomainResult.Success(executions)
} }
private suspend fun collectSnapshot(profileId: ProviderProfileId, scenes: List<Scene>): AutomationSnapshot { private suspend fun collectSnapshot(profileId: ProviderProfileId, scenes: List<Scene>): AutomationSnapshot {
@@ -184,3 +194,6 @@ class DefaultAutomationEngine(
} }
} }
internal fun SceneTrigger.Time.automationDefinition(): String =
listOf(timeOfDay, daysOfWeek.sorted().joinToString(",")).joinToString("|")
@@ -37,10 +37,11 @@ class AutomationPollWorker @AssistedInject constructor(
val scheduledOccurrence = inputData.getString(WorkManagerAutomationScheduleStore.KEY_OCCURRENCE_KEY)?.let { key -> val scheduledOccurrence = inputData.getString(WorkManagerAutomationScheduleStore.KEY_OCCURRENCE_KEY)?.let { key ->
val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID) val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID)
val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID) val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID)
val triggerDefinition = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_DEFINITION)
val scheduledAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_SCHEDULED_AT_EPOCH_MILLIS, -1L) val scheduledAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_SCHEDULED_AT_EPOCH_MILLIS, -1L)
val expiresAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_EXPIRES_AT_EPOCH_MILLIS, -1L) val expiresAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_EXPIRES_AT_EPOCH_MILLIS, -1L)
if (sceneId != null && triggerId != null && scheduledAt >= 0L && expiresAt >= scheduledAt) { if (sceneId != null && triggerId != null && triggerDefinition != null && scheduledAt >= 0L && expiresAt >= scheduledAt) {
ScheduledAutomationOccurrence(sceneId, triggerId, key, scheduledAt, expiresAt) ScheduledAutomationOccurrence(sceneId, triggerId, key, triggerDefinition, scheduledAt, expiresAt)
} else null } else null
} }
when (automationEngine.evaluate(profileId, scheduledOccurrence)) { when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
@@ -24,7 +24,6 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build()) .setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
.build() .build()
workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request) workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request)
workManager.cancelAllWorkByTag(timeWorkTag(profileId))
scenes.forEach { scene -> scenes.forEach { scene ->
scene.triggers.filterIsInstance<SceneTrigger.Time>().forEach { trigger -> scene.triggers.filterIsInstance<SceneTrigger.Time>().forEach { trigger ->
scheduleNextTimeTrigger(profileId, scene, trigger) scheduleNextTimeTrigger(profileId, scene, trigger)
@@ -40,8 +39,8 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
} }
private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}" private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}"
private fun timeWorkName(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time) = private fun timeWorkName(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time, occurrenceKey: String) =
"automation-time:${profileId.value}:${scene.id.value}:${trigger.id.value}" "automation-time:${profileId.value}:${scene.id.value}:${trigger.id.value}:$occurrenceKey"
private fun timeWorkTag(profileId: ProviderProfileId) = "automation-time:${profileId.value}" private fun timeWorkTag(profileId: ProviderProfileId) = "automation-time:${profileId.value}"
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time) { private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time) {
@@ -55,6 +54,7 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
?.toInstant() ?.toInstant()
?.minusMillis(1) ?.minusMillis(1)
val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry
val occurrenceKey = "${scheduledAt.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}"
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>() val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
.setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS) .setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS)
.addTag(timeWorkTag(profileId)) .addTag(timeWorkTag(profileId))
@@ -63,15 +63,16 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
.putString(KEY_PROFILE_ID, profileId.value) .putString(KEY_PROFILE_ID, profileId.value)
.putString(KEY_SCENE_ID, scene.id.value) .putString(KEY_SCENE_ID, scene.id.value)
.putString(KEY_TRIGGER_ID, trigger.id.value) .putString(KEY_TRIGGER_ID, trigger.id.value)
.putString(KEY_OCCURRENCE_KEY, "${scheduledAt.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}") .putString(KEY_OCCURRENCE_KEY, occurrenceKey)
.putString(KEY_TRIGGER_DEFINITION, trigger.automationDefinition())
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli()) .putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli())
.putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli()) .putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli())
.build(), .build(),
) )
.build() .build()
workManager.enqueueUniqueWork( workManager.enqueueUniqueWork(
timeWorkName(profileId, scene, trigger), timeWorkName(profileId, scene, trigger, occurrenceKey),
androidx.work.ExistingWorkPolicy.REPLACE, androidx.work.ExistingWorkPolicy.KEEP,
request, request,
) )
} }
@@ -92,6 +93,7 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
const val KEY_SCENE_ID = "scene_id" const val KEY_SCENE_ID = "scene_id"
const val KEY_TRIGGER_ID = "trigger_id" const val KEY_TRIGGER_ID = "trigger_id"
const val KEY_OCCURRENCE_KEY = "occurrence_key" const val KEY_OCCURRENCE_KEY = "occurrence_key"
const val KEY_TRIGGER_DEFINITION = "trigger_definition"
const val KEY_SCHEDULED_AT_EPOCH_MILLIS = "scheduled_at_epoch_millis" const val KEY_SCHEDULED_AT_EPOCH_MILLIS = "scheduled_at_epoch_millis"
const val KEY_EXPIRES_AT_EPOCH_MILLIS = "expires_at_epoch_millis" const val KEY_EXPIRES_AT_EPOCH_MILLIS = "expires_at_epoch_millis"