fix(scene): isolate scheduled automation execution

This commit is contained in:
2026-08-28 02:36:11 +08:00
parent 076a1daf5d
commit fe285322ef
6 changed files with 86 additions and 20 deletions
@@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay 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.first
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -30,20 +31,20 @@ class AutomationBootstrapper(
combine( combine(
profileStore.observeActiveProfile(), profileStore.observeActiveProfile(),
sessionCoordinator.observeActiveSession(), sessionCoordinator.observeActiveSession(),
isForeground, ) { active, session -> active to session }
) { active, session, foreground -> Triple(active, session, foreground) } .collectLatest { (active, session) ->
.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 (foreground) { while (true) {
isForeground.first { it }
try { try {
engine.evaluate(active.id) engine.evaluate(active.id)
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
throw cancelled throw cancelled
} catch (_: Exception) {} } catch (_: Exception) {}
delay(60_000L) if (isForeground.value) delay(60_000L)
} }
} else if (active != null) { } else if (active != null) {
scheduler.cancel(active.id) scheduler.cancel(active.id)
@@ -28,9 +28,18 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
interface AutomationEngine { interface AutomationEngine {
suspend fun evaluate(profileId: ProviderProfileId): DomainResult<Int> suspend fun evaluate(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence? = null,
): DomainResult<Int>
} }
data class ScheduledAutomationOccurrence(
val sceneId: String,
val triggerId: String,
val occurrenceKey: String,
)
class DefaultAutomationEngine( class DefaultAutomationEngine(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
private val runtimeRepository: AutomationRuntimeRepository, private val runtimeRepository: AutomationRuntimeRepository,
@@ -43,11 +52,17 @@ class DefaultAutomationEngine(
) : AutomationEngine { ) : AutomationEngine {
private val evaluationMutex = Mutex() private val evaluationMutex = Mutex()
override suspend fun evaluate(profileId: ProviderProfileId): DomainResult<Int> = evaluationMutex.withLock { override suspend fun evaluate(
evaluateLocked(profileId) profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<Int> = evaluationMutex.withLock {
evaluateLocked(profileId, scheduledOccurrence)
} }
private suspend fun evaluateLocked(profileId: ProviderProfileId): DomainResult<Int> { private suspend fun evaluateLocked(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<Int> {
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 }
@@ -70,7 +85,15 @@ class DefaultAutomationEngine(
is DomainResult.Success -> state.value is DomainResult.Success -> state.value
} }
} }
val evaluation = triggerEvaluator.evaluate(trigger, snapshot, now, wasMatched) val evaluation = if (
trigger is SceneTrigger.Time &&
scheduledOccurrence?.sceneId == scene.id.value &&
scheduledOccurrence.triggerId == trigger.id.value
) {
TriggerEvaluation.Matched(scheduledOccurrence.occurrenceKey)
} else {
triggerEvaluator.evaluate(trigger, snapshot, now, wasMatched)
}
val isMatched = evaluation is TriggerEvaluation.Matched val isMatched = evaluation is TriggerEvaluation.Matched
if (trigger is SceneTrigger.Time && evaluation is TriggerEvaluation.Matched) { if (trigger is SceneTrigger.Time && evaluation is TriggerEvaluation.Matched) {
newlyMatched = true newlyMatched = true
@@ -28,12 +28,17 @@ class AutomationPollWorker @AssistedInject constructor(
if (profileStore.activeProfile()?.id != profileId) return Result.success() if (profileStore.activeProfile()?.id != profileId) return Result.success()
sessionCoordinator.bootstrap() sessionCoordinator.bootstrap()
return when (val validity = sessionCoordinator.validateActiveSession()) { return when (val validity = sessionCoordinator.validateSession(profileId)) {
is DomainResult.Failure -> Result.retry() is DomainResult.Failure -> Result.retry()
is DomainResult.Success -> if (validity.value != SessionValidity.VALID) { is DomainResult.Success -> if (validity.value != SessionValidity.VALID) {
Result.retry() Result.retry()
} else { } else {
when (automationEngine.evaluate(profileId)) { val scheduledOccurrence = inputData.getString(WorkManagerAutomationScheduleStore.KEY_OCCURRENCE_KEY)?.let { key ->
val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID)
val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID)
if (sceneId != null && triggerId != null) ScheduledAutomationOccurrence(sceneId, triggerId, key) else null
}
when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
is DomainResult.Success -> { is DomainResult.Success -> {
automationScheduler.reconcile(profileId) automationScheduler.reconcile(profileId)
Result.success() Result.success()
@@ -36,7 +36,7 @@ class ProfileAutomationScheduler(
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> { override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
val active = profileStore.activeProfile() val active = profileStore.activeProfile()
if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active")) if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))
return when (val validity = sessionCoordinator.validateActiveSession()) { return when (val validity = sessionCoordinator.validateSession(profileId)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) { is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
when (val result = sceneRepository.observeScenes(profileId).first()) { when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> result is DomainResult.Failure -> result
@@ -39,16 +39,25 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scenes: List<Scene>) { private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scenes: List<Scene>) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val candidates = scenes.flatMap { scene -> scene.triggers.filterIsInstance<SceneTrigger.Time>() } val next = scenes.flatMap { scene ->
.mapNotNull { trigger -> nextOccurrence(trigger, now) } scene.triggers.filterIsInstance<SceneTrigger.Time>().mapNotNull { trigger ->
val next = candidates.minOrNull() ?: run { nextOccurrence(trigger, now)?.let { occurrence -> ScheduledWorkOccurrence(scene, trigger, occurrence) }
}
}.minByOrNull { it.scheduledAt } ?: run {
workManager.cancelUniqueWork(timeWorkName(profileId)) workManager.cancelUniqueWork(timeWorkName(profileId))
return return
} }
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), next.atZone(ZoneId.systemDefault()).toInstant()) val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), next.scheduledAt.atZone(ZoneId.systemDefault()).toInstant())
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>() val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
.setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS) .setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS)
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build()) .setInputData(
androidx.work.Data.Builder()
.putString(KEY_PROFILE_ID, profileId.value)
.putString(KEY_SCENE_ID, next.scene.id.value)
.putString(KEY_TRIGGER_ID, next.trigger.id.value)
.putString(KEY_OCCURRENCE_KEY, "${next.scheduledAt.toLocalDate()}:${next.trigger.timeOfDay}:${next.trigger.id.value}")
.build(),
)
.build() .build()
workManager.enqueueUniqueWork(timeWorkName(profileId), androidx.work.ExistingWorkPolicy.REPLACE, request) workManager.enqueueUniqueWork(timeWorkName(profileId), androidx.work.ExistingWorkPolicy.REPLACE, request)
} }
@@ -64,5 +73,16 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
return null return null
} }
companion object { const val KEY_PROFILE_ID = "profile_id" } private data class ScheduledWorkOccurrence(
val scene: Scene,
val trigger: SceneTrigger.Time,
val scheduledAt: LocalDateTime,
)
companion object {
const val KEY_PROFILE_ID = "profile_id"
const val KEY_SCENE_ID = "scene_id"
const val KEY_TRIGGER_ID = "trigger_id"
const val KEY_OCCURRENCE_KEY = "occurrence_key"
}
} }
@@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -173,7 +174,23 @@ class SessionCoordinator(
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation { suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
val profile = profileStore.activeProfile() val profile = profileStore.activeProfile()
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID) ?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
profile.gateway().validateSession(profile.id) validateSession(profile.id)
}
suspend fun validateSession(profileId: ProviderProfileId): DomainResult<SessionValidity> = storeOperation {
val profile = profileStore.activeProfile()
if (profile?.id != profileId) return@storeOperation DomainResult.Success(SessionValidity.INVALID)
val session = profile.gateway().observeSession(profileId).first()
if (session?.profileId != profileId) return@storeOperation DomainResult.Success(SessionValidity.INVALID)
when (val validity = profile.gateway().validateSession(profileId)) {
is DomainResult.Failure -> validity
is DomainResult.Success -> {
val stillActive = profileStore.activeProfile()?.id == profileId
val currentSession = profile.gateway().observeSession(profileId).first()
if (stillActive && currentSession?.profileId == profileId) validity
else DomainResult.Success(SessionValidity.INVALID)
}
}
} }
suspend fun logout(): DomainResult<Unit> = storeOperation { suspend fun logout(): DomainResult<Unit> = storeOperation {