diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 13dab95..a985b5d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(project(":android:core:session")) implementation(project(":android:feature:infrared")) implementation(project(":android:feature:automation")) + implementation(libs.androidx.hilt.work) implementation(project(":absdk-bridge")) implementation(project(":runtime:simulation")) debugImplementation(project(":testing:fakes")) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 508eb01..2b57476 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + + + + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ABoxManagerApplication.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ABoxManagerApplication.kt index e23073c..1cbb64d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ABoxManagerApplication.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ABoxManagerApplication.kt @@ -1,7 +1,22 @@ package com.flagship.abox.manager import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import com.flagship.abox.manager.automation.runtime.AutomationBootstrapper import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp -class ABoxManagerApplication : Application() +class ABoxManagerApplication : Application(), Configuration.Provider { + @Inject lateinit var workerFactory: HiltWorkerFactory + @Inject lateinit var automationBootstrapper: AutomationBootstrapper + + override fun getWorkManagerConfiguration(): Configuration = + Configuration.Builder().setWorkerFactory(workerFactory).build() + + override fun onCreate() { + super.onCreate() + automationBootstrapper.start() + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/MainActivity.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/MainActivity.kt index 77a62c1..2ec1705 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/MainActivity.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/MainActivity.kt @@ -1,19 +1,47 @@ package com.flagship.abox.manager +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.core.content.ContextCompat import com.flagship.abox.manager.ui.ABoxManagerApp import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val notificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) {} + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { ABoxManagerApp() } + requestNotificationPermissionIfNeeded() + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + ) { + return + } + + val permissionState = getSharedPreferences(NOTIFICATION_PERMISSION_STATE, MODE_PRIVATE) + if (permissionState.getBoolean(KEY_PERMISSION_REQUESTED, false)) return + + permissionState.edit().putBoolean(KEY_PERMISSION_REQUESTED, true).apply() + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + private companion object { + const val NOTIFICATION_PERMISSION_STATE = "notification_permission_state" + const val KEY_PERMISSION_REQUESTED = "permission_requested" } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt index a2c537f..b7ada87 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt @@ -1,10 +1,10 @@ package com.flagship.abox.manager.di import com.flagship.abox.manager.domain.DefaultSceneExecutor -import com.flagship.abox.manager.domain.InMemorySceneRepository import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.NotificationGateway import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction import com.flagship.abox.manager.domain.ActivityEventWriter @@ -21,22 +21,20 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object SceneIntegrationModule { - @Provides - @Singleton - fun provideSceneRepository(): SceneRepository = InMemorySceneRepository() - @Provides @Singleton fun provideSceneExecutor( socketGateway: SocketGateway, infraredGateway: InfraredGateway, sendVirtualRemoteAction: SendVirtualRemoteAction, + notificationGateway: NotificationGateway, activityWriter: ActivityEventWriter, ): SceneExecutor = RecordingSceneExecutor( delegate = DefaultSceneExecutor( socketGateway = socketGateway, infraredGateway = infraredGateway, sendVirtualRemoteAction = sendVirtualRemoteAction, + notificationGateway = notificationGateway, ), writer = activityWriter, ) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index b4c6199..0152fd3 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -218,93 +218,6 @@ private fun SceneEditorContent( } } - item(key = "icon_color_card") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - stringResource(R.string.scene_section_other), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - listOf( - SceneIcon.HOME to "Home", - SceneIcon.NIGHT to "Night", - SceneIcon.AWAY to "Away", - SceneIcon.MOVIE to "Movie", - SceneIcon.CUSTOM to "Custom", - ).forEach { (icon, name) -> - val isSelected = state.icon == icon - Surface( - shape = RoundedCornerShape(12.dp), - color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier - .size(52.dp) - .clickable { viewModel.updateIcon(icon) }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = when (icon) { - SceneIcon.HOME -> Icons.Outlined.CheckCircle - SceneIcon.NIGHT -> Icons.Outlined.Schedule - SceneIcon.AWAY -> Icons.Outlined.HourglassDisabled - SceneIcon.MOVIE -> Icons.Outlined.PlayCircle - else -> Icons.Outlined.Settings - }, - contentDescription = name, - tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(26.dp), - ) - } - } - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - listOf( - "#3F51B5", "#E91E63", "#26A69A", "#FF8A65", "#673AB7", "#4CAF50", - ).forEach { hex -> - val isSelected = state.colorHex.equals(hex, ignoreCase = true) - val parsedColor = try { - Color(android.graphics.Color.parseColor(hex)) - } catch (e: Exception) { - MaterialTheme.colorScheme.primary - } - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(parsedColor) - .clickable { viewModel.updateColor(hex) }, - contentAlignment = Alignment.Center, - ) { - if (isSelected) { - Icon( - Icons.Filled.Check, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp), - ) - } - } - } - } - } - } - } - if (state.type == AutomationType.AUTOMATIC) { item(key = "triggers_card") { OutlinedCard( @@ -552,6 +465,93 @@ private fun SceneEditorContent( } } + item(key = "icon_color_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.scene_section_other), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + listOf( + SceneIcon.HOME to "Home", + SceneIcon.NIGHT to "Night", + SceneIcon.AWAY to "Away", + SceneIcon.MOVIE to "Movie", + SceneIcon.CUSTOM to "Custom", + ).forEach { (icon, name) -> + val isSelected = state.icon == icon + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier + .size(52.dp) + .clickable { viewModel.updateIcon(icon) }, + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = when (icon) { + SceneIcon.HOME -> Icons.Outlined.CheckCircle + SceneIcon.NIGHT -> Icons.Outlined.Schedule + SceneIcon.AWAY -> Icons.Outlined.HourglassDisabled + SceneIcon.MOVIE -> Icons.Outlined.PlayCircle + else -> Icons.Outlined.Settings + }, + contentDescription = name, + tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + listOf( + "#3F51B5", "#E91E63", "#26A69A", "#FF8A65", "#673AB7", "#4CAF50", + ).forEach { hex -> + val isSelected = state.colorHex.equals(hex, ignoreCase = true) + val parsedColor = try { + Color(android.graphics.Color.parseColor(hex)) + } catch (e: Exception) { + MaterialTheme.colorScheme.primary + } + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(parsedColor) + .clickable { viewModel.updateColor(hex) }, + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + Icons.Filled.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } + } + item(key = "save_section") { state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) 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 4be21f1..894b79b 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 @@ -28,6 +28,7 @@ import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import com.flagship.abox.manager.automation.runtime.AutomationScheduler import dagger.hilt.android.lifecycle.HiltViewModel import java.util.UUID import javax.inject.Inject @@ -69,6 +70,7 @@ class SceneEditorViewModel @Inject constructor( private val virtualRemoteRepository: VirtualRemoteRepository, private val profileStore: ProviderProfileStore, private val deviceCatalog: DeviceCatalog, + private val automationScheduler: AutomationScheduler? = null, ) : ViewModel() { private val _state = MutableStateFlow(SceneEditorState()) @@ -395,7 +397,10 @@ class SceneEditorViewModel @Inject constructor( } _state.update { current -> when (result) { - is DomainResult.Success -> current.copy(isSaving = false, isSaved = true) + is DomainResult.Success -> { + automationScheduler?.reconcile(profile.id) + current.copy(isSaving = false, isSaved = true) + } is DomainResult.Failure -> current.copy( isSaving = false, errorMessage = result.error.detail ?: "Save failed", diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index 1e182de..019470c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -111,9 +111,8 @@ fun SceneListScreen( val context = LocalContext.current LaunchedEffect(Unit) { viewModel.userMessage.collectLatest { msg -> - if (msg == "executed") { - Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show() - } + val messageRes = if (msg == "executed") R.string.scene_toast_executed else R.string.scene_toast_failed + Toast.makeText(context, context.getString(messageRes), Toast.LENGTH_SHORT).show() } } @@ -402,19 +401,17 @@ private fun SceneCard( } Row(verticalAlignment = Alignment.CenterVertically) { - if (scene.type == AutomationType.MANUAL) { - FilledTonalButton( - onClick = onQuickExecute, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - modifier = Modifier.height(32.dp), - shape = RoundedCornerShape(8.dp), - ) { - Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium) - } + FilledTonalButton( + onClick = onQuickExecute, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(8.dp), + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium) } + Spacer(Modifier.width(4.dp)) IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt index 22f2e39..e394d68 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt @@ -9,6 +9,7 @@ import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.automation.runtime.AutomationScheduler import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -38,6 +39,7 @@ class SceneListViewModel @Inject constructor( private val sceneRepository: SceneRepository, private val sceneExecutor: SceneExecutor, private val profileStore: ProviderProfileStore, + private val automationScheduler: AutomationScheduler? = null, ) : ViewModel() { private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL) @@ -84,21 +86,29 @@ class SceneListViewModel @Inject constructor( viewModelScope.launch { val profile = profileStore.activeProfile() ?: return@launch val updated = scene.copy(enabled = !scene.enabled) - sceneRepository.updateScene(profile.id, updated) + if (sceneRepository.updateScene(profile.id, updated) is DomainResult.Success) { + automationScheduler?.reconcile(profile.id) + } } } fun executeQuick(scene: Scene) { viewModelScope.launch { - sceneExecutor.execute(scene) - _userMessage.emit("executed") + when (val result = sceneExecutor.execute(scene)) { + is DomainResult.Success -> { + _userMessage.emit(if (result.value.failedCount == 0) "executed" else "failed") + } + is DomainResult.Failure -> _userMessage.emit("failed") + } } } fun deleteScene(sceneId: SceneId) { viewModelScope.launch { val profile = profileStore.activeProfile() ?: return@launch - sceneRepository.deleteScene(profile.id, sceneId) + if (sceneRepository.deleteScene(profile.id, sceneId) is DomainResult.Success) { + automationScheduler?.reconcile(profile.id) + } _pendingDelete.value = null } } diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index adcff17..5f289f9 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -57,6 +57,7 @@ 点击右下角按钮,建立一键指令或带触发器的自动指令。 立即执行 指令已触发执行 + 指令执行失败 删除指令 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 677acab..3fc5def 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -57,6 +57,7 @@ Tap the button below to create instant commands or trigger-based automatic rules. Run Now Command triggered + Command execution failed Delete Command diff --git a/android/feature/automation/build.gradle.kts b/android/feature/automation/build.gradle.kts index e7fce16..4fab89c 100644 --- a/android/feature/automation/build.gradle.kts +++ b/android/feature/automation/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(libs.hilt.android) kapt(libs.hilt.compiler) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.material3) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt index 6fd478b..b162fea 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow @Dao @@ -23,4 +24,35 @@ interface AutomationDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long + + @Query("SELECT * FROM scenes WHERE profileId = :profileId ORDER BY updatedAtEpochMillis, id") + fun observeScenes(profileId: String): Flow> + + @Query("SELECT * FROM scenes WHERE profileId = :profileId ORDER BY updatedAtEpochMillis, id") + suspend fun getScenes(profileId: String): List + + @Query("SELECT * FROM scenes WHERE profileId = :profileId AND id = :sceneId LIMIT 1") + suspend fun getScene(profileId: String, sceneId: String): SceneEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertScene(scene: SceneEntity) + + @Query("DELETE FROM scenes WHERE profileId = :profileId AND id = :sceneId") + suspend fun deleteScene(profileId: String, sceneId: String): Int + + @Query("SELECT * FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId AND triggerId = :triggerId LIMIT 1") + suspend fun getTriggerState(profileId: String, sceneId: String, triggerId: String): AutomationTriggerStateEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertTriggerState(state: AutomationTriggerStateEntity) + + @Transaction + suspend fun transitionTriggerState(state: AutomationTriggerStateEntity): Boolean { + val previous = getTriggerState(state.profileId, state.sceneId, state.triggerId)?.matched ?: false + upsertTriggerState(state) + return !previous && state.matched + } + + @Query("DELETE FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId") + suspend fun deleteTriggerStates(profileId: String, sceneId: String): Int } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt index 3a8a677..2e17313 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt @@ -3,7 +3,16 @@ package com.flagship.abox.manager.automation.data import androidx.room.Database import androidx.room.RoomDatabase -@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false) +@Database( + entities = [ + ActivityEventEntity::class, + AutomationOccurrenceEntity::class, + SceneEntity::class, + AutomationTriggerStateEntity::class, + ], + version = 2, + exportSchema = false, +) abstract class AutomationDatabase : RoomDatabase() { abstract fun automationDao(): AutomationDao } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt index 735ab7d..211ebf3 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt @@ -31,3 +31,29 @@ data class AutomationOccurrenceEntity( val triggerId: String, val occurrenceKey: String, ) + +@Entity( + tableName = "scenes", + primaryKeys = ["profileId", "id"], + indices = [Index(value = ["profileId", "type", "enabled"])], +) +data class SceneEntity( + val profileId: String, + val id: String, + val type: String, + val enabled: Boolean, + val document: String, + val updatedAtEpochMillis: Long, +) + +@Entity( + tableName = "automation_trigger_state", + primaryKeys = ["profileId", "sceneId", "triggerId"], +) +data class AutomationTriggerStateEntity( + val profileId: String, + val sceneId: String, + val triggerId: String, + val matched: Boolean, + val updatedAtEpochMillis: Long, +) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationMigrations.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationMigrations.kt new file mode 100644 index 0000000..882f4ef --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationMigrations.kt @@ -0,0 +1,35 @@ +package com.flagship.abox.manager.automation.data + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS scenes ( + profileId TEXT NOT NULL, + id TEXT NOT NULL, + type TEXT NOT NULL, + enabled INTEGER NOT NULL, + document TEXT NOT NULL, + updatedAtEpochMillis INTEGER NOT NULL, + PRIMARY KEY(profileId, id) + ) + """.trimIndent(), + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_scenes_profileId_type_enabled ON scenes(profileId, type, enabled)") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS automation_trigger_state ( + profileId TEXT NOT NULL, + sceneId TEXT NOT NULL, + triggerId TEXT NOT NULL, + matched INTEGER NOT NULL, + updatedAtEpochMillis INTEGER NOT NULL, + PRIMARY KEY(profileId, sceneId, triggerId) + ) + """.trimIndent(), + ) + } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt index 0506e92..8d74c34 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt @@ -6,13 +6,23 @@ import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneTriggerId -data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean) - interface AutomationRuntimeRepository { suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult + suspend fun isMatched(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId): DomainResult + suspend fun transitionMatch(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, isMatched: Boolean, nowEpochMillis: Long): DomainResult } class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository { + override suspend fun isMatched( + profileId: ProviderProfileId, + sceneId: SceneId, + triggerId: SceneTriggerId, + ): DomainResult = try { + DomainResult.Success(dao.getTriggerState(profileId.value, sceneId.value, triggerId.value)?.matched ?: false) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable")) + } + override suspend fun claimOccurrence( profileId: ProviderProfileId, sceneId: SceneId, @@ -26,4 +36,25 @@ class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : Automati } catch (error: Exception) { DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable")) } + + override suspend fun transitionMatch( + profileId: ProviderProfileId, + sceneId: SceneId, + triggerId: SceneTriggerId, + isMatched: Boolean, + nowEpochMillis: Long, + ): DomainResult = try { + val transitioned = dao.transitionTriggerState( + AutomationTriggerStateEntity( + profileId = profileId.value, + sceneId = sceneId.value, + triggerId = triggerId.value, + matched = isMatched, + updatedAtEpochMillis = nowEpochMillis, + ), + ) + DomainResult.Success(transitioned) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable")) + } } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomSceneRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomSceneRepository.kt new file mode 100644 index 0000000..4c2abcc --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomSceneRepository.kt @@ -0,0 +1,104 @@ +package com.flagship.abox.manager.automation.data + +import android.content.Context +import com.flagship.abox.manager.domain.AutomationType +import com.flagship.abox.manager.domain.ComparisonOperator +import com.flagship.abox.manager.domain.DeviceName +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.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneCondition +import com.flagship.abox.manager.domain.SceneConditionId +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SceneTrigger +import com.flagship.abox.manager.domain.SceneTriggerId +import com.flagship.abox.manager.domain.SocketPowerState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +class RoomSceneRepository( + context: Context, + private val dao: AutomationDao, +) : SceneRepository { + private val seedPreferences = context.getSharedPreferences("scene_seed_state", Context.MODE_PRIVATE) + + override fun observeScenes(profileId: ProviderProfileId): Flow>> = flow { + ensureSeeded(profileId) + emitAll(dao.observeScenes(profileId.value) + .map, DomainResult>> { entities -> DomainResult.Success(entities.map(SceneDocumentCodec::decode)) } + .catch { emit(DomainResult.Failure(DomainError.Unknown(detail = "Scene storage unavailable"))) }) + } + + override suspend fun getScene(profileId: ProviderProfileId, sceneId: SceneId): DomainResult = runCatching { + dao.getScene(profileId.value, sceneId.value)?.let(SceneDocumentCodec::decode) + }.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.Unknown(detail = "Scene storage unavailable")) }) + + override suspend fun createScene(profileId: ProviderProfileId, scene: Scene): DomainResult = save(profileId, scene, requireExisting = false) + + override suspend fun updateScene(profileId: ProviderProfileId, scene: Scene): DomainResult = save(profileId, scene, requireExisting = true) + + override suspend fun deleteScene(profileId: ProviderProfileId, sceneId: SceneId): DomainResult = runCatching { + val deleted = dao.deleteScene(profileId.value, sceneId.value) + dao.deleteTriggerStates(profileId.value, sceneId.value) + if (deleted == 0) throw IllegalArgumentException("Scene not found") + }.fold({ DomainResult.Success(Unit) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) }) + + private suspend fun save(profileId: ProviderProfileId, scene: Scene, requireExisting: Boolean): DomainResult { + if (scene.profileId != profileId || scene.actions.any { it.profileId != profileId }) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch")) + } + return runCatching { + if (requireExisting && dao.getScene(profileId.value, scene.id.value) == null) throw IllegalArgumentException("Scene not found") + if (!requireExisting && dao.getScene(profileId.value, scene.id.value) != null) throw IllegalArgumentException("Scene already exists") + dao.upsertScene(scene.toEntity()) + dao.deleteTriggerStates(profileId.value, scene.id.value) + scene + }.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) }) + } + + private suspend fun ensureSeeded(profileId: ProviderProfileId) { + val marker = "seed_v1_${profileId.value}" + if (seedPreferences.getBoolean(marker, false)) return + defaultScenes(profileId).forEach { scene -> + dao.upsertScene(scene.toEntity()) + } + seedPreferences.edit().putBoolean(marker, true).apply() + } + + private fun Scene.toEntity() = SceneEntity(profileId.value, id.value, type.name, enabled, SceneDocumentCodec.encode(this), System.currentTimeMillis()) + + private fun defaultScenes(profileId: ProviderProfileId): List = listOf( + Scene( + SceneId("${profileId.value}:scene-arriving-home"), profileId, "回家模式", "开启 Zigbee 插座控制器", + AutomationType.MANUAL, true, SceneIcon.HOME, SceneColor("#4CAF50"), + actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:arrive:socket"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.ON)), + ), + Scene( + SceneId("${profileId.value}:scene-leaving-home"), profileId, "离家模式", "关闭 Zigbee 插座控制器", + AutomationType.MANUAL, true, SceneIcon.AWAY, SceneColor("#FF8A65"), + actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:leave:socket"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.OFF)), + ), + Scene( + SceneId("${profileId.value}:scene-temperature-alert"), profileId, "高温提醒", "室温高于 28°C 时发送提醒", + AutomationType.AUTOMATIC, true, SceneIcon.THERMOSTAT, SceneColor("#EF5350"), + triggers = listOf(SceneTrigger.Temperature(SceneTriggerId("${profileId.value}:temp-alert"), DeviceName("温湿度传感器"), ComparisonOperator.GREATER_THAN, 28.0, "温度高于 28°C")), + actions = listOf(SceneAction.Notification(SceneActionId("${profileId.value}:temp-alert:notification"), profileId, "温度提醒", "当前室温已高于 28°C")), + ), + Scene( + SceneId("${profileId.value}:scene-cooling-auto"), profileId, "高温自动降温", "当室温高于 28°C 时开启插座", + AutomationType.AUTOMATIC, true, SceneIcon.THERMOSTAT, SceneColor("#26A69A"), + triggers = listOf(SceneTrigger.Temperature(SceneTriggerId("${profileId.value}:cooling-temp"), DeviceName("温湿度传感器"), ComparisonOperator.GREATER_THAN, 28.0, "温度高于 28°C")), + conditions = listOf(SceneCondition.SocketPower(SceneConditionId("${profileId.value}:cooling-socket"), DeviceName("Zigbee插座控制器"), SocketPowerState.OFF)), + actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:cooling:on"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.ON)), + ), + ) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/SceneDocumentCodec.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/SceneDocumentCodec.kt new file mode 100644 index 0000000..83ad8b7 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/SceneDocumentCodec.kt @@ -0,0 +1,135 @@ +package com.flagship.abox.manager.automation.data + +import com.flagship.abox.manager.domain.AutomationType +import com.flagship.abox.manager.domain.ComparisonOperator +import com.flagship.abox.manager.domain.ConcurrencyMode +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ExecutionStrategy +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneCondition +import com.flagship.abox.manager.domain.SceneConditionId +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneTrigger +import com.flagship.abox.manager.domain.SceneTriggerId +import com.flagship.abox.manager.domain.SocketPowerState +import org.json.JSONArray +import org.json.JSONObject + +internal object SceneDocumentCodec { + fun encode(scene: Scene): String = JSONObject() + .put("name", scene.name) + .put("description", scene.description) + .put("type", scene.type.name) + .put("enabled", scene.enabled) + .put("icon", scene.icon.name) + .put("color", scene.color.hex) + .put("strategy", scene.strategy.name) + .put("concurrency", scene.concurrencyMode.name) + .put("logging", scene.enableLogging) + .put("triggers", JSONArray().apply { scene.triggers.forEach { put(encodeTrigger(it)) } }) + .put("conditions", JSONArray().apply { scene.conditions.forEach { put(encodeCondition(it)) } }) + .put("actions", JSONArray().apply { scene.actions.forEach { put(encodeAction(it)) } }) + .toString() + + fun decode(entity: SceneEntity): Scene { + val document = JSONObject(entity.document) + val profileId = ProviderProfileId(entity.profileId) + return Scene( + id = SceneId(entity.id), + profileId = profileId, + name = document.getString("name"), + description = document.optString("description"), + type = enumValue(document.getString("type"), AutomationType.MANUAL), + enabled = document.optBoolean("enabled", true), + icon = enumValue(document.optString("icon"), SceneIcon.HOME), + color = SceneColor(document.optString("color", "#3F51B5")), + triggers = document.optJSONArray("triggers").objects().map(::decodeTrigger), + conditions = document.optJSONArray("conditions").objects().map(::decodeCondition), + actions = document.optJSONArray("actions").objects().map { decodeAction(it, profileId) }, + strategy = enumValue(document.optString("strategy"), ExecutionStrategy.CONTINUE_ON_ERROR), + concurrencyMode = enumValue(document.optString("concurrency"), ConcurrencyMode.SEQUENTIAL), + enableLogging = document.optBoolean("logging", true), + ) + } + + private fun encodeTrigger(trigger: SceneTrigger): JSONObject = JSONObject().put("id", trigger.id.value).apply { + when (trigger) { + is SceneTrigger.Time -> put("kind", "time").put("time", trigger.timeOfDay) + .put("days", JSONArray(trigger.daysOfWeek.sorted())).put("label", trigger.label) + is SceneTrigger.Temperature -> put("kind", "temperature").put("device", trigger.deviceName.value) + .put("operator", trigger.operator.name).put("threshold", trigger.thresholdCelsius).put("label", trigger.label) + is SceneTrigger.Humidity -> put("kind", "humidity").put("device", trigger.deviceName.value) + .put("operator", trigger.operator.name).put("threshold", trigger.thresholdPercent).put("label", trigger.label) + is SceneTrigger.DeviceState -> put("kind", "device_state").put("device", trigger.deviceName.value) + .put("state", trigger.expectedState.name).put("label", trigger.label) + } + } + + private fun decodeTrigger(value: JSONObject): SceneTrigger { + val id = SceneTriggerId(value.getString("id")) + return when (value.getString("kind")) { + "time" -> SceneTrigger.Time(id, value.getString("time"), value.optJSONArray("days").ints().toSet(), value.optString("label")) + "temperature" -> SceneTrigger.Temperature(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"), value.optString("label")) + "humidity" -> SceneTrigger.Humidity(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"), value.optString("label")) + "device_state" -> SceneTrigger.DeviceState(id, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF), value.optString("label")) + else -> error("Unsupported trigger kind") + } + } + + private fun encodeCondition(condition: SceneCondition): JSONObject = JSONObject().put("id", condition.id.value).apply { + when (condition) { + is SceneCondition.Temperature -> put("kind", "temperature").put("device", condition.deviceName.value) + .put("operator", condition.operator.name).put("threshold", condition.thresholdCelsius) + is SceneCondition.Humidity -> put("kind", "humidity").put("device", condition.deviceName.value) + .put("operator", condition.operator.name).put("threshold", condition.thresholdPercent) + is SceneCondition.SocketPower -> put("kind", "socket").put("device", condition.deviceName.value) + .put("state", condition.expectedState.name) + is SceneCondition.TimeRange -> put("kind", "time_range").put("start", condition.startTime).put("end", condition.endTime) + } + } + + private fun decodeCondition(value: JSONObject): SceneCondition { + val id = SceneConditionId(value.getString("id")) + return when (value.getString("kind")) { + "temperature" -> SceneCondition.Temperature(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold")) + "humidity" -> SceneCondition.Humidity(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold")) + "socket" -> SceneCondition.SocketPower(id, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF)) + "time_range" -> SceneCondition.TimeRange(id, value.getString("start"), value.getString("end")) + else -> error("Unsupported condition kind") + } + } + + private fun encodeAction(action: SceneAction): JSONObject = JSONObject().put("id", action.id.value).apply { + when (action) { + is SceneAction.SocketPower -> put("kind", "socket").put("device", action.deviceName.value).put("state", action.powerState.name) + is SceneAction.VirtualRemoteButton -> put("kind", "remote").put("remoteId", action.remoteId) + .put("remoteName", action.remoteDisplayName).put("buttonId", action.buttonId).put("buttonName", action.buttonDisplayName) + is SceneAction.InfraredKey -> put("kind", "infrared").put("device", action.deviceName.value).put("key", action.key) + is SceneAction.Delay -> put("kind", "delay").put("seconds", action.durationSeconds) + is SceneAction.Notification -> put("kind", "notification").put("title", action.title).put("message", action.message) + } + } + + private fun decodeAction(value: JSONObject, profileId: ProviderProfileId): SceneAction { + val id = SceneActionId(value.getString("id")) + return when (value.getString("kind")) { + "socket" -> SceneAction.SocketPower(id, profileId, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF)) + "remote" -> SceneAction.VirtualRemoteButton(id, profileId, value.getString("remoteId"), value.optString("remoteName"), value.getString("buttonId"), value.optString("buttonName")) + "infrared" -> SceneAction.InfraredKey(id, profileId, DeviceName(value.getString("device")), value.getString("key")) + "delay" -> SceneAction.Delay(id, profileId, value.getInt("seconds")) + "notification" -> SceneAction.Notification(id, profileId, value.getString("title"), value.optString("message")) + else -> error("Unsupported action kind") + } + } + + private inline fun > enumValue(value: String, fallback: T): T = + enumValues().firstOrNull { it.name == value } ?: fallback + + private fun JSONArray?.objects(): List = if (this == null) emptyList() else List(length()) { getJSONObject(it) } + private fun JSONArray?.ints(): List = if (this == null) emptyList() else List(length()) { getInt(it) } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt index 6d5d42c..6cfa23c 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt @@ -6,9 +6,24 @@ import com.flagship.abox.manager.automation.data.AutomationDatabase import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository import com.flagship.abox.manager.automation.data.RoomActivityRepository import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository +import com.flagship.abox.manager.automation.data.RoomSceneRepository +import com.flagship.abox.manager.automation.data.MIGRATION_1_2 +import com.flagship.abox.manager.automation.runtime.AutomationBootstrapper +import com.flagship.abox.manager.automation.runtime.AutomationEngine +import com.flagship.abox.manager.automation.runtime.AutomationScheduleStore +import com.flagship.abox.manager.automation.runtime.AutomationScheduler +import com.flagship.abox.manager.automation.runtime.DefaultAutomationEngine +import com.flagship.abox.manager.automation.runtime.ProfileAutomationScheduler +import com.flagship.abox.manager.automation.runtime.WorkManagerAutomationScheduleStore import com.flagship.abox.manager.domain.ActivityEventWriter import com.flagship.abox.manager.domain.ActivityRepository import com.flagship.abox.manager.domain.NotificationGateway +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.SceneExecutor +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway import dagger.Module import dagger.Provides @@ -26,7 +41,9 @@ object AutomationModule { @Provides @Singleton fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase = - Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build() + Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db") + .addMigrations(MIGRATION_1_2) + .build() @Provides @Singleton @@ -41,6 +58,57 @@ object AutomationModule { @Singleton fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao()) + @Provides + @Singleton + fun provideSceneRepository( + @ApplicationContext context: Context, + database: AutomationDatabase, + ): SceneRepository = RoomSceneRepository(context, database.automationDao()) + + @Provides + @Singleton + fun provideAutomationEngine( + sceneRepository: SceneRepository, + runtimeRepository: AutomationRuntimeRepository, + temperatureHumidityGateway: TemperatureHumidityGateway, + socketGateway: SocketGateway, + sceneExecutor: SceneExecutor, + ): AutomationEngine = DefaultAutomationEngine( + sceneRepository, + runtimeRepository, + temperatureHumidityGateway, + socketGateway, + sceneExecutor, + ) + + @Provides + @Singleton + fun provideAutomationScheduleStore(@ApplicationContext context: Context): AutomationScheduleStore = + WorkManagerAutomationScheduleStore(context) + + @Provides + @Singleton + fun provideAutomationScheduler( + profileStore: ProviderProfileStore, + sessionCoordinator: SessionCoordinator, + sceneRepository: SceneRepository, + scheduleStore: AutomationScheduleStore, + ): AutomationScheduler = ProfileAutomationScheduler(profileStore, sessionCoordinator, sceneRepository, scheduleStore) + + @Provides + @Singleton + fun provideAutomationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Provides + @Singleton + fun provideAutomationBootstrapper( + profileStore: ProviderProfileStore, + sessionCoordinator: SessionCoordinator, + scheduler: AutomationScheduler, + engine: AutomationEngine, + scope: CoroutineScope, + ): AutomationBootstrapper = AutomationBootstrapper(profileStore, sessionCoordinator, scheduler, engine, scope) + @Provides @Singleton fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway = diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt index 329d536..58fc23f 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager import android.annotation.SuppressLint +import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -16,34 +17,50 @@ import com.flagship.abox.manager.domain.ProviderProfileId class AndroidNotificationGateway(private val context: Context) : NotificationGateway { override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult { - if (android.os.Build.VERSION.SDK_INT >= 33 && + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { - return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) + return notificationUnavailable() } + + val notificationManager = NotificationManagerCompat.from(context) + if (!notificationManager.areNotificationsEnabled()) return notificationUnavailable() + val manager = context.getSystemService(NotificationManager::class.java) - manager.createNotificationChannel( - NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT), - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT), + ) + if (manager.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE) { + return notificationUnavailable() + } + } + return try { @SuppressLint("MissingPermission") fun postNotification() { - NotificationManagerCompat.from(context).notify( - profileId.value.hashCode(), - NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle(title) - .setContentText(message) - .setAutoCancel(true) - .build(), + notificationManager.notify( + profileId.value.hashCode(), + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setAutoCancel(true) + .build(), ) } postNotification() DomainResult.Success(Unit) } catch (_: SecurityException) { - DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) + notificationUnavailable() } } - private companion object { const val CHANNEL_ID = "automation_events" } + private fun notificationUnavailable(): DomainResult.Failure = + DomainResult.Failure(DomainError.OperationFailed(detail = "Notifications are not allowed")) + + private companion object { + const val CHANNEL_ID = "automation_events" + } } 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 index 373ce7a..fca1797 100644 --- 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 @@ -1,24 +1,45 @@ package com.flagship.abox.manager.automation.runtime import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.SessionCoordinator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.CancellationException class AutomationBootstrapper( private val profileStore: ProviderProfileStore, + private val sessionCoordinator: SessionCoordinator, private val scheduler: AutomationScheduler, + private val engine: AutomationEngine, private val scope: CoroutineScope, ) { private var previousProfileId: ProviderProfileId? = null fun start() { scope.launch { - profileStore.observeActiveProfile().collectLatest { active -> + combine( + profileStore.observeActiveProfile(), + sessionCoordinator.observeActiveSession(), + ) { active, session -> active to session }.collectLatest { (active, session) -> previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) } previousProfileId = active?.id - scheduler.reconcileActiveProfile() + if (active != null && session?.profileId == active.id) { + scheduler.reconcile(active.id) + while (true) { + try { + engine.evaluate(active.id) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) {} + delay(60_000L) + } + } else if (active != null) { + scheduler.cancel(active.id) + } } } } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationEngine.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationEngine.kt new file mode 100644 index 0000000..b8df156 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationEngine.kt @@ -0,0 +1,156 @@ +package com.flagship.abox.manager.automation.runtime + +import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository +import com.flagship.abox.manager.domain.AutomationSnapshot +import com.flagship.abox.manager.domain.AutomationType +import com.flagship.abox.manager.domain.ConditionsEvaluation +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneCondition +import com.flagship.abox.manager.domain.SceneConditionEvaluator +import com.flagship.abox.manager.domain.SceneExecutionId +import com.flagship.abox.manager.domain.SceneExecutionSource +import com.flagship.abox.manager.domain.SceneExecutor +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SceneTrigger +import com.flagship.abox.manager.domain.SceneTriggerEvaluator +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.domain.TriggerEvaluation +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.UUID +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface AutomationEngine { + suspend fun evaluate(profileId: ProviderProfileId): DomainResult +} + +class DefaultAutomationEngine( + private val sceneRepository: SceneRepository, + private val runtimeRepository: AutomationRuntimeRepository, + private val temperatureHumidityGateway: TemperatureHumidityGateway, + private val socketGateway: SocketGateway, + private val sceneExecutor: SceneExecutor, + private val triggerEvaluator: SceneTriggerEvaluator = SceneTriggerEvaluator(), + private val conditionEvaluator: SceneConditionEvaluator = SceneConditionEvaluator(), + private val nowMillis: () -> Long = System::currentTimeMillis, +) : AutomationEngine { + private val evaluationMutex = Mutex() + + override suspend fun evaluate(profileId: ProviderProfileId): DomainResult = evaluationMutex.withLock { + evaluateLocked(profileId) + } + + private suspend fun evaluateLocked(profileId: ProviderProfileId): DomainResult { + 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 } + } + if (scenes.isEmpty()) return DomainResult.Success(0) + + val snapshot = collectSnapshot(profileId, scenes) + val now = LocalDateTime.ofInstant(Instant.ofEpochMilli(snapshot.observedAtEpochMillis), ZoneId.systemDefault()) + var executed = 0 + for (scene in scenes) { + var newlyMatched = false + val newlyMatchedStateTriggers = mutableListOf() + val matchedTimeTriggers = mutableListOf>() + for (trigger in scene.triggers) { + val wasMatched = if (trigger is SceneTrigger.Time) { + false + } else { + when (val state = runtimeRepository.isMatched(profileId, scene.id, trigger.id)) { + is DomainResult.Failure -> return state + is DomainResult.Success -> state.value + } + } + val evaluation = triggerEvaluator.evaluate(trigger, snapshot, now, wasMatched) + val isMatched = evaluation is TriggerEvaluation.Matched + if (trigger is SceneTrigger.Time && evaluation is TriggerEvaluation.Matched) { + newlyMatched = true + matchedTimeTriggers += trigger to evaluation.occurrenceKey + } else if (trigger !is SceneTrigger.Time && evaluation !is TriggerEvaluation.Unavailable) { + when (val transition = runtimeRepository.transitionMatch( + profileId, + scene.id, + trigger.id, + isMatched, + snapshot.observedAtEpochMillis, + )) { + is DomainResult.Failure -> return transition + is DomainResult.Success -> if (transition.value) { + newlyMatched = true + newlyMatchedStateTriggers += trigger + } + } + } + } + if (!newlyMatched) continue + if (conditionEvaluator.evaluate(scene.conditions, snapshot, now) !is ConditionsEvaluation.Satisfied) { + newlyMatchedStateTriggers.forEach { trigger -> + runtimeRepository.transitionMatch( + profileId, + scene.id, + trigger.id, + false, + snapshot.observedAtEpochMillis, + ) + } + continue + } + if (matchedTimeTriggers.isNotEmpty()) { + var claimedTimeTrigger = false + for ((trigger, occurrenceKey) in matchedTimeTriggers) { + when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) { + is DomainResult.Failure -> return claim + is DomainResult.Success -> if (claim.value) claimedTimeTrigger = true + } + } + if (!claimedTimeTrigger && newlyMatchedStateTriggers.isEmpty()) continue + } + + 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 collectSnapshot(profileId: ProviderProfileId, scenes: List): AutomationSnapshot { + val readingDevices = linkedSetOf() + val socketDevices = linkedSetOf() + scenes.forEach { scene -> + scene.triggers.forEach { trigger -> + when (trigger) { + is SceneTrigger.Temperature -> readingDevices += trigger.deviceName + is SceneTrigger.Humidity -> readingDevices += trigger.deviceName + is SceneTrigger.DeviceState -> socketDevices += trigger.deviceName + is SceneTrigger.Time -> Unit + } + } + scene.conditions.forEach { condition -> + when (condition) { + is SceneCondition.Temperature -> readingDevices += condition.deviceName + is SceneCondition.Humidity -> readingDevices += condition.deviceName + is SceneCondition.SocketPower -> socketDevices += condition.deviceName + is SceneCondition.TimeRange -> Unit + } + } + } + return AutomationSnapshot( + observedAtEpochMillis = nowMillis(), + readings = readingDevices.associateWith { temperatureHumidityGateway.getReading(profileId, it) }, + socketStatuses = socketDevices.associateWith { socketGateway.getStatus(profileId, it) }, + ) + } +} 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 index 6478dd9..9d0370e 100644 --- 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 @@ -1,10 +1,46 @@ package com.flagship.abox.manager.automation.runtime import android.content.Context +import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +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.SessionCoordinator +import com.flagship.abox.manager.domain.SessionValidity +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject -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() +@HiltWorker +class AutomationPollWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val profileStore: ProviderProfileStore, + private val sessionCoordinator: SessionCoordinator, + private val automationEngine: AutomationEngine, + private val automationScheduler: AutomationScheduler, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val rawProfileId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID) + ?: return Result.failure() + val profileId = ProviderProfileId(rawProfileId) + if (profileStore.activeProfile()?.id != profileId) return Result.success() + + sessionCoordinator.bootstrap() + return when (val validity = sessionCoordinator.validateActiveSession()) { + is DomainResult.Failure -> Result.retry() + is DomainResult.Success -> if (validity.value != SessionValidity.VALID) { + Result.retry() + } else { + when (automationEngine.evaluate(profileId)) { + is DomainResult.Success -> { + automationScheduler.reconcile(profileId) + Result.success() + } + is DomainResult.Failure -> Result.retry() + } + } + } + } } 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 index b8fd478..343f814 100644 --- 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 @@ -7,7 +7,7 @@ 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.SessionCoordinator import com.flagship.abox.manager.domain.SessionValidity import kotlinx.coroutines.flow.first @@ -24,7 +24,7 @@ interface AutomationScheduleStore { class ProfileAutomationScheduler( private val profileStore: ProviderProfileStore, - private val sessionGateway: SessionGateway, + private val sessionCoordinator: SessionCoordinator, private val sceneRepository: SceneRepository, private val scheduleStore: AutomationScheduleStore, ) : AutomationScheduler { @@ -36,7 +36,7 @@ class ProfileAutomationScheduler( 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)) { + return when (val validity = sessionCoordinator.validateActiveSession()) { is DomainResult.Success -> if (validity.value == SessionValidity.VALID) { when (val result = sceneRepository.observeScenes(profileId).first()) { is DomainResult.Failure -> result 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 index 5fa0e17..e89cf38 100644 --- 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 @@ -3,11 +3,17 @@ package com.flagship.abox.manager.automation.runtime import android.content.Context import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.OneTimeWorkRequestBuilder 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 +import java.time.Duration +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import com.flagship.abox.manager.domain.SceneTrigger class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore { private val workManager = WorkManager.getInstance(context) @@ -18,15 +24,45 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS .setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build()) .build() workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request) + scheduleNextTimeTrigger(profileId, scenes) return DomainResult.Success(Unit) } override suspend fun remove(profileId: ProviderProfileId): DomainResult { workManager.cancelUniqueWork(workName(profileId)) + workManager.cancelUniqueWork(timeWorkName(profileId)) return DomainResult.Success(Unit) } private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}" + private fun timeWorkName(profileId: ProviderProfileId) = "automation-time:${profileId.value}" + + private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scenes: List) { + val now = LocalDateTime.now() + val candidates = scenes.flatMap { scene -> scene.triggers.filterIsInstance() } + .mapNotNull { trigger -> nextOccurrence(trigger, now) } + val next = candidates.minOrNull() ?: run { + workManager.cancelUniqueWork(timeWorkName(profileId)) + return + } + val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), next.atZone(ZoneId.systemDefault()).toInstant()) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS) + .setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build()) + .build() + workManager.enqueueUniqueWork(timeWorkName(profileId), androidx.work.ExistingWorkPolicy.REPLACE, request) + } + + private fun nextOccurrence(trigger: SceneTrigger.Time, now: LocalDateTime): LocalDateTime? { + val time = runCatching { LocalTime.parse(trigger.timeOfDay) }.getOrNull() ?: return null + for (offset in 0..7) { + val date = now.toLocalDate().plusDays(offset.toLong()) + val candidate = LocalDateTime.of(date, time) + val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek + if (dayMatches && candidate.isAfter(now)) return candidate + } + return null + } companion object { const val KEY_PROFILE_ID = "profile_id" } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt index 941bd73..76c7a47 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt @@ -5,6 +5,7 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.format.DateTimeFormatter +import java.time.Duration interface AutomationClock { fun nowEpochMillis(): Long @@ -40,11 +41,34 @@ class SceneTriggerEvaluator { } } + fun evaluate( + trigger: SceneTrigger, + snapshot: AutomationSnapshot, + now: LocalDateTime, + wasMatched: Boolean = false, + ): TriggerEvaluation = when (trigger) { + is SceneTrigger.Temperature -> threshold( + snapshot.readings[trigger.deviceName], + trigger.operator, + trigger.thresholdCelsius, + hysteresis = if (wasMatched) TEMPERATURE_HYSTERESIS else 0.0, + ) { it.temperature } ?: TriggerEvaluation.NotMatched + is SceneTrigger.Humidity -> threshold( + snapshot.readings[trigger.deviceName], + trigger.operator, + trigger.thresholdPercent, + hysteresis = if (wasMatched) HUMIDITY_HYSTERESIS else 0.0, + ) { it.humidity } ?: TriggerEvaluation.NotMatched + else -> evaluateOne(trigger, snapshot, now) ?: TriggerEvaluation.NotMatched + } + private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) { is SceneTrigger.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 - if (dayMatches && now.toLocalTime().hour == time.hour && now.toLocalTime().minute == time.minute) { + val scheduled = LocalDateTime.of(now.toLocalDate(), time) + val lateness = Duration.between(scheduled, now).toMinutes() + if (dayMatches && lateness in 0..20) { TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}") } else null } @@ -69,6 +93,7 @@ class SceneTriggerEvaluator { result: DomainResult?, operator: ComparisonOperator, threshold: Double, + hysteresis: Double = 0.0, value: (TemperatureHumidityReading) -> String?, ): TriggerEvaluation? { if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold")) @@ -78,7 +103,7 @@ class SceneTriggerEvaluator { is DomainResult.Success -> { val actual = value(result.value)?.toDoubleOrNull() ?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) - if (compare(actual, operator, threshold)) TriggerEvaluation.Matched("threshold") else null + if (compareWithHysteresis(actual, operator, threshold, hysteresis)) TriggerEvaluation.Matched("threshold") else null } } } @@ -91,7 +116,25 @@ class SceneTriggerEvaluator { ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold } + private fun compareWithHysteresis( + actual: Double, + operator: ComparisonOperator, + threshold: Double, + hysteresis: Double, + ): Boolean = when (operator) { + ComparisonOperator.GREATER_THAN -> actual > threshold - hysteresis + ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold - hysteresis + ComparisonOperator.LESS_THAN -> actual < threshold + hysteresis + ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + hysteresis + ComparisonOperator.EQUALS -> kotlin.math.abs(actual - threshold) <= maxOf(0.1, hysteresis) + } + private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull() + + private companion object { + const val TEMPERATURE_HYSTERESIS = 0.5 + const val HUMIDITY_HYSTERESIS = 2.0 + } } class SceneConditionEvaluator { diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt index fe01285..2c2e41d 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -22,6 +22,7 @@ class DefaultSceneExecutor( private val socketGateway: SocketGateway, private val infraredGateway: InfraredGateway, private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null, + private val notificationGateway: NotificationGateway? = null, private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) }, ) : SceneExecutor { private val runningScenes = ConcurrentHashMap() @@ -222,11 +223,15 @@ class DefaultSceneExecutor( } is SceneAction.Notification -> { - SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.SUCCESS, - error = null, - ) + if (notificationGateway == null) { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = DomainError.OperationFailed(detail = "Notification gateway is unavailable"), + ) + } else { + toExecutionResult(action, notificationGateway.show(profileId, action.title, action.message)) + } } } } catch (e: CancellationException) {