diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index e38d4ee..02619dd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -191,11 +191,13 @@ class SceneEditorViewModel @Inject constructor( } fun addDeviceStateTrigger(deviceName: String, expectedState: String) { + val parsedState = runCatching { SocketPowerState.valueOf(expectedState.trim().uppercase()) }.getOrNull() + ?: return val trigger = SceneTrigger.DeviceState( id = SceneTriggerId(UUID.randomUUID().toString()), deviceName = DeviceName(deviceName), - expectedState = expectedState, - label = "$deviceName 状态变为 $expectedState", + expectedState = parsedState, + label = "$deviceName 状态变为 ${parsedState.name}", ) _state.update { it.copy(triggers = it.triggers + trigger) } } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt new file mode 100644 index 0000000..be5b730 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.automation.runtime + +import com.flagship.abox.manager.domain.ProviderProfileStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class AutomationBootstrapper( + private val profileStore: ProviderProfileStore, + private val scheduler: AutomationScheduler, + private val scope: CoroutineScope, +) { + fun start() { + scope.launch { profileStore.observeActiveProfile().collectLatest { scheduler.reconcileActiveProfile() } } + } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt new file mode 100644 index 0000000..6478dd9 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt @@ -0,0 +1,10 @@ +package com.flagship.abox.manager.automation.runtime + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +class AutomationPollWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result = + if (inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID).isNullOrBlank()) Result.failure() else Result.success() +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt new file mode 100644 index 0000000..b8fd478 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt @@ -0,0 +1,53 @@ +package com.flagship.abox.manager.automation.runtime + +import com.flagship.abox.manager.domain.AutomationType +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SessionGateway +import com.flagship.abox.manager.domain.SessionValidity +import kotlinx.coroutines.flow.first + +interface AutomationScheduler { + suspend fun reconcileActiveProfile(): DomainResult + suspend fun reconcile(profileId: ProviderProfileId): DomainResult + suspend fun cancel(profileId: ProviderProfileId): DomainResult +} + +interface AutomationScheduleStore { + suspend fun replace(profileId: ProviderProfileId, scenes: List): DomainResult + suspend fun remove(profileId: ProviderProfileId): DomainResult +} + +class ProfileAutomationScheduler( + private val profileStore: ProviderProfileStore, + private val sessionGateway: SessionGateway, + private val sceneRepository: SceneRepository, + private val scheduleStore: AutomationScheduleStore, +) : AutomationScheduler { + override suspend fun reconcileActiveProfile(): DomainResult { + val active = profileStore.activeProfile() ?: return DomainResult.Success(Unit) + return reconcile(active.id) + } + + override suspend fun reconcile(profileId: ProviderProfileId): DomainResult { + val active = profileStore.activeProfile() + if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active")) + return when (val validity = sessionGateway.validateSession(profileId)) { + is DomainResult.Success -> if (validity.value == SessionValidity.VALID) { + when (val result = sceneRepository.observeScenes(profileId).first()) { + is DomainResult.Failure -> result + is DomainResult.Success -> scheduleStore.replace(profileId, result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }) + } + } else { + scheduleStore.remove(profileId) + } + is DomainResult.Failure -> scheduleStore.remove(profileId) + } + } + + override suspend fun cancel(profileId: ProviderProfileId): DomainResult = scheduleStore.remove(profileId) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt new file mode 100644 index 0000000..4fd9cd9 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.automation.runtime + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import java.util.concurrent.TimeUnit + +class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore { + private val workManager = WorkManager.getInstance(context) + + override suspend fun replace(profileId: ProviderProfileId, scenes: List): DomainResult { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build()) + .build() + workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request) + return DomainResult.Success(Unit) + } + + override suspend fun remove(profileId: ProviderProfileId): DomainResult { + workManager.cancelUniqueWork(workName(profileId)) + return DomainResult.Success(Unit) + } + + private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}" + + companion object { const val KEY_PROFILE_ID = "profile_id" } +}