fix(scene): isolate scheduled automation execution
This commit is contained in:
+6
-5
@@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -30,20 +31,20 @@ class AutomationBootstrapper(
|
||||
combine(
|
||||
profileStore.observeActiveProfile(),
|
||||
sessionCoordinator.observeActiveSession(),
|
||||
isForeground,
|
||||
) { active, session, foreground -> Triple(active, session, foreground) }
|
||||
.collectLatest { (active, session, foreground) ->
|
||||
) { active, session -> active to session }
|
||||
.collectLatest { (active, session) ->
|
||||
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
|
||||
previousProfileId = active?.id
|
||||
if (active != null && session?.profileId == active.id) {
|
||||
scheduler.reconcile(active.id)
|
||||
while (foreground) {
|
||||
while (true) {
|
||||
isForeground.first { it }
|
||||
try {
|
||||
engine.evaluate(active.id)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: Exception) {}
|
||||
delay(60_000L)
|
||||
if (isForeground.value) delay(60_000L)
|
||||
}
|
||||
} else if (active != null) {
|
||||
scheduler.cancel(active.id)
|
||||
|
||||
+28
-5
@@ -28,9 +28,18 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
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(
|
||||
private val sceneRepository: SceneRepository,
|
||||
private val runtimeRepository: AutomationRuntimeRepository,
|
||||
@@ -43,11 +52,17 @@ class DefaultAutomationEngine(
|
||||
) : AutomationEngine {
|
||||
private val evaluationMutex = Mutex()
|
||||
|
||||
override suspend fun evaluate(profileId: ProviderProfileId): DomainResult<Int> = evaluationMutex.withLock {
|
||||
evaluateLocked(profileId)
|
||||
override suspend fun evaluate(
|
||||
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()) {
|
||||
is DomainResult.Failure -> return result
|
||||
is DomainResult.Success -> result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }
|
||||
@@ -70,7 +85,15 @@ class DefaultAutomationEngine(
|
||||
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
|
||||
if (trigger is SceneTrigger.Time && evaluation is TriggerEvaluation.Matched) {
|
||||
newlyMatched = true
|
||||
|
||||
+7
-2
@@ -28,12 +28,17 @@ class AutomationPollWorker @AssistedInject constructor(
|
||||
if (profileStore.activeProfile()?.id != profileId) return Result.success()
|
||||
|
||||
sessionCoordinator.bootstrap()
|
||||
return when (val validity = sessionCoordinator.validateActiveSession()) {
|
||||
return when (val validity = sessionCoordinator.validateSession(profileId)) {
|
||||
is DomainResult.Failure -> Result.retry()
|
||||
is DomainResult.Success -> if (validity.value != SessionValidity.VALID) {
|
||||
Result.retry()
|
||||
} 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 -> {
|
||||
automationScheduler.reconcile(profileId)
|
||||
Result.success()
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ class ProfileAutomationScheduler(
|
||||
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
|
||||
val active = profileStore.activeProfile()
|
||||
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) {
|
||||
when (val result = sceneRepository.observeScenes(profileId).first()) {
|
||||
is DomainResult.Failure -> result
|
||||
|
||||
+26
-6
@@ -39,16 +39,25 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
|
||||
|
||||
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scenes: List<Scene>) {
|
||||
val now = LocalDateTime.now()
|
||||
val candidates = scenes.flatMap { scene -> scene.triggers.filterIsInstance<SceneTrigger.Time>() }
|
||||
.mapNotNull { trigger -> nextOccurrence(trigger, now) }
|
||||
val next = candidates.minOrNull() ?: run {
|
||||
val next = scenes.flatMap { scene ->
|
||||
scene.triggers.filterIsInstance<SceneTrigger.Time>().mapNotNull { trigger ->
|
||||
nextOccurrence(trigger, now)?.let { occurrence -> ScheduledWorkOccurrence(scene, trigger, occurrence) }
|
||||
}
|
||||
}.minByOrNull { it.scheduledAt } ?: run {
|
||||
workManager.cancelUniqueWork(timeWorkName(profileId))
|
||||
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>()
|
||||
.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()
|
||||
workManager.enqueueUniqueWork(timeWorkName(profileId), androidx.work.ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
@@ -64,5 +73,16 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user