feat(scene): complete automation runtime #11

Merged
KremeCN merged 9 commits from feat/scene-automation-runtime into main 2026-08-28 06:39:31 +08:00
30 changed files with 1141 additions and 157 deletions
+1
View File
@@ -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"))
+12 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
@@ -11,6 +12,16 @@
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.ABoxManager">
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true">
@@ -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()
}
}
@@ -1,19 +1,38 @@
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
}
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -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,
)
@@ -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)
@@ -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,
Review

失败吞掉

场景持久化后,reconcile 的失败结果被忽略,界面仍直接标记保存成功。若用户在没有现有周期任务时创建首个自动场景,而会话校验或调度存储发生瞬时失败,该场景将没有后台任务且不会自动重试,直至重启或再次修改。应记录失败并安排重试或暴露调度失败状态。

**失败吞掉** 场景持久化后,`reconcile` 的失败结果被忽略,界面仍直接标记保存成功。若用户在没有现有周期任务时创建首个自动场景,而会话校验或调度存储发生瞬时失败,该场景将没有后台任务且不会自动重试,直至重启或再次修改。应记录失败并安排重试或暴露调度失败状态。 <!-- pr-agent-dedup: 1a79d15dad7a --> <!-- pr-agent-key-issue-location: 256a8d28a01c -->
errorMessage = result.error.detail ?: "Save failed",
@@ -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)
}
@@ -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
}
}
@@ -57,6 +57,7 @@
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
<string name="scene_action_run_now">立即执行</string>
<string name="scene_toast_executed">指令已触发执行</string>
<string name="scene_toast_failed">指令执行失败</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">删除指令</string>
@@ -57,6 +57,7 @@
<string name="scene_empty_message">Tap the button below to create instant commands or trigger-based automatic rules.</string>
<string name="scene_action_run_now">Run Now</string>
<string name="scene_toast_executed">Command triggered</string>
<string name="scene_toast_failed">Command execution failed</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">Delete Command</string>
@@ -39,6 +39,9 @@ dependencies {
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
kapt(libs.androidx.hilt.compiler)
implementation(libs.androidx.lifecycle.process)
implementation(platform(libs.compose.bom))
Review

处理器缺失

这里只加入了 AndroidX Hilt Work 运行库,但 @HiltWorker 还需要在声明 Worker 的模块中配置 AndroidX Hilt 编译器。否则不会生成并注册 Worker assisted factory,WorkManager 无法通过 HiltWorkerFactory 实例化 AutomationPollWorker,后台自动化任务会在运行时失败;应增加对应的 kapt 或 ksp 编译器依赖。

**处理器缺失** 这里只加入了 AndroidX Hilt Work 运行库,但 `@HiltWorker` 还需要在声明 Worker 的模块中配置 AndroidX Hilt 编译器。否则不会生成并注册 Worker assisted factory,WorkManager 无法通过 `HiltWorkerFactory` 实例化 `AutomationPollWorker`,后台自动化任务会在运行时失败;应增加对应的 `kapt` 或 `ksp` 编译器依赖。 <!-- pr-agent-dedup: df5ab63303b0 --> <!-- pr-agent-key-issue-location: f5a5ce5a7abc -->
implementation(libs.compose.ui)
implementation(libs.compose.material3)
@@ -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,38 @@ 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<List<SceneEntity>>
@Query("SELECT * FROM scenes WHERE profileId = :profileId ORDER BY updatedAtEpochMillis, id")
suspend fun getScenes(profileId: String): List<SceneEntity>
@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
@Query("DELETE FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId AND triggerId = :triggerId")
suspend fun deleteTriggerState(profileId: String, sceneId: String, triggerId: String): Int
}
@@ -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
}
@@ -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,
)
@@ -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(),
)
}
}
@@ -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<Boolean>
suspend fun isMatched(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId): DomainResult<Boolean>
suspend fun transitionMatch(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, isMatched: Boolean, nowEpochMillis: Long): DomainResult<Boolean>
}
class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository {
override suspend fun isMatched(
profileId: ProviderProfileId,
sceneId: SceneId,
triggerId: SceneTriggerId,
): DomainResult<Boolean> = 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<Boolean> = 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"))
}
}
@@ -0,0 +1,125 @@
package com.flagship.abox.manager.automation.data
import android.content.Context
import androidx.room.withTransaction
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 database: AutomationDatabase,
) : SceneRepository {
private val dao = database.automationDao()
private val seedPreferences = context.getSharedPreferences("scene_seed_state", Context.MODE_PRIVATE)
override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> = flow {
ensureSeeded(profileId)
emitAll(dao.observeScenes(profileId.value)
.map<List<SceneEntity>, DomainResult<List<Scene>>> { 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<Scene?> = 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<Scene> = save(profileId, scene, requireExisting = false)
override suspend fun updateScene(profileId: ProviderProfileId, scene: Scene): DomainResult<Scene> = save(profileId, scene, requireExisting = true)
override suspend fun deleteScene(profileId: ProviderProfileId, sceneId: SceneId): DomainResult<Unit> = 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<Scene> {
if (scene.profileId != profileId || scene.actions.any { it.profileId != profileId }) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch"))
}
return runCatching {
database.withTransaction {
val existing = dao.getScene(profileId.value, scene.id.value)
if (requireExisting && existing == null) throw IllegalArgumentException("Scene not found")
if (!requireExisting && existing != null) throw IllegalArgumentException("Scene already exists")
dao.upsertScene(scene.toEntity())
existing?.let(SceneDocumentCodec::decode)?.triggers.orEmpty()
.filter { previous -> scene.triggers.none { current -> current.hasSameDefinitionAs(previous) } }
.forEach { changed ->
dao.deleteTriggerState(profileId.value, scene.id.value, changed.id.value)
}
}
scene
}.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) })
}
private fun SceneTrigger.hasSameDefinitionAs(other: SceneTrigger): Boolean = when {
this is SceneTrigger.Time && other is SceneTrigger.Time ->
id == other.id && timeOfDay == other.timeOfDay && daysOfWeek == other.daysOfWeek
this is SceneTrigger.Temperature && other is SceneTrigger.Temperature ->
id == other.id && deviceName == other.deviceName && operator == other.operator && thresholdCelsius == other.thresholdCelsius
this is SceneTrigger.Humidity && other is SceneTrigger.Humidity ->
id == other.id && deviceName == other.deviceName && operator == other.operator && thresholdPercent == other.thresholdPercent
this is SceneTrigger.DeviceState && other is SceneTrigger.DeviceState ->
id == other.id && deviceName == other.deviceName && expectedState == other.expectedState
else -> false
}
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<Scene> = 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)),
),
)
}
@@ -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 <reified T : Enum<T>> enumValue(value: String, fallback: T): T =
enumValues<T>().firstOrNull { it.name == value } ?: fallback
private fun JSONArray?.objects(): List<JSONObject> = if (this == null) emptyList() else List(length()) { getJSONObject(it) }
private fun JSONArray?.ints(): List<Int> = if (this == null) emptyList() else List(length()) { getInt(it) }
}
@@ -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)
@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 =
@@ -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
@@ -13,37 +14,54 @@ import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.UUID
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<Unit> {
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(
UUID.randomUUID().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"
}
}
@@ -1,25 +1,78 @@
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
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
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 com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.CancellationException
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
class AutomationBootstrapper(
private val profileStore: ProviderProfileStore,
private val sessionCoordinator: SessionCoordinator,
private val scheduler: AutomationScheduler,
private val engine: AutomationEngine,
private val scope: CoroutineScope,
) {
) : DefaultLifecycleObserver {
private var previousProfileId: ProviderProfileId? = null
private val isForeground = MutableStateFlow(false)
fun start() {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
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) {
var scheduleReady = false
while (true) {
isForeground.first { it }
when (val validity = sessionCoordinator.validateSession(active.id)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
if (!scheduleReady) {
scheduleReady = scheduler.reconcile(active.id) is DomainResult.Success
}
if (scheduleReady) {
try {
engine.evaluate(active.id)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {}
}
} else {
scheduler.cancel(active.id)
scheduleReady = false
}
is DomainResult.Failure -> Unit
}
if (isForeground.value) delay(60_000L)
}
} else if (active != null) {
scheduler.cancel(active.id)
}
}
}
}
override fun onStart(owner: LifecycleOwner) {
isForeground.value = true
}
override fun onStop(owner: LifecycleOwner) {
isForeground.value = false
}
}
@@ -0,0 +1,199 @@
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,
scheduledOccurrence: ScheduledAutomationOccurrence? = null,
): DomainResult<Int>
}
data class ScheduledAutomationOccurrence(
val sceneId: String,
val triggerId: String,
val occurrenceKey: String,
val triggerDefinition: String,
val scheduledAtEpochMillis: Long,
val expiresAtEpochMillis: Long,
)
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,
scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<Int> {
val scenesToExecute = when (val prepared = evaluationMutex.withLock { prepareExecutions(profileId, scheduledOccurrence) }) {
is DomainResult.Failure -> return prepared
is DomainResult.Success -> prepared.value
}
var executed = 0
for (scene in scenesToExecute) {
sceneExecutor.execute(
scene = scene,
executionId = SceneExecutionId("auto-${UUID.randomUUID()}"),
source = SceneExecutionSource.AUTOMATION,
).collect { update ->
if (update is com.flagship.abox.manager.domain.SceneExecutionUpdate.Completed) executed++
}
}
return DomainResult.Success(executed)
}
private suspend fun prepareExecutions(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<List<Scene>> {
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(emptyList())
val snapshot = collectSnapshot(profileId, scenes)
val now = LocalDateTime.ofInstant(Instant.ofEpochMilli(snapshot.observedAtEpochMillis), ZoneId.systemDefault())
val executions = mutableListOf<Scene>()
for (scene in scenes) {
var newlyMatched = false
val newlyMatchedStateTriggers = mutableListOf<SceneTrigger>()
val matchedTimeTriggers = mutableListOf<Pair<SceneTrigger.Time, String>>()
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 = if (
trigger is SceneTrigger.Time &&
scheduledOccurrence?.sceneId == scene.id.value &&
scheduledOccurrence.triggerId == trigger.id.value &&
scheduledOccurrence.triggerDefinition == trigger.automationDefinition() &&
snapshot.observedAtEpochMillis in scheduledOccurrence.scheduledAtEpochMillis..scheduledOccurrence.expiresAtEpochMillis
) {
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
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
when (conditionEvaluator.evaluate(scene.conditions, snapshot, now)) {
is ConditionsEvaluation.Unavailable -> continue
is ConditionsEvaluation.NotSatisfied -> {
for ((trigger, occurrenceKey) in matchedTimeTriggers) {
when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) {
is DomainResult.Failure -> return claim
is DomainResult.Success -> Unit
}
}
continue
}
ConditionsEvaluation.Satisfied -> Unit
}
var executionCount = if (newlyMatchedStateTriggers.isEmpty()) 0 else 1
if (matchedTimeTriggers.isNotEmpty()) {
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) executionCount++
}
}
}
if (executionCount == 0) continue
repeat(executionCount) {
executions += scene
}
}
return DomainResult.Success(executions)
}
private suspend fun collectSnapshot(profileId: ProviderProfileId, scenes: List<Scene>): AutomationSnapshot {
val readingDevices = linkedSetOf<DeviceName>()
val socketDevices = linkedSetOf<DeviceName>()
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) },
)
}
}
internal fun SceneTrigger.Time.automationDefinition(): String =
listOf(timeOfDay, daysOfWeek.sorted().joinToString(",")).joinToString("|")
@@ -1,10 +1,57 @@
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.validateSession(profileId)) {
is DomainResult.Failure -> Result.retry()
is DomainResult.Success -> if (validity.value != SessionValidity.VALID) {
automationScheduler.cancel(profileId)
Result.success()
} else {
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)
val triggerDefinition = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_DEFINITION)
val scheduledAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_SCHEDULED_AT_EPOCH_MILLIS, -1L)
val expiresAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_EXPIRES_AT_EPOCH_MILLIS, -1L)
if (sceneId != null && triggerId != null && triggerDefinition != null && scheduledAt >= 0L && expiresAt >= scheduledAt) {
ScheduledAutomationOccurrence(sceneId, triggerId, key, triggerDefinition, scheduledAt, expiresAt)
} else null
}
when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
is DomainResult.Success -> {
automationScheduler.reconcile(profileId)
Result.success()
}
is DomainResult.Failure -> Result.retry()
}
}
}
}
}
@@ -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<Unit> {
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.validateSession(profileId)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> result
@@ -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,79 @@ 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)
scenes.forEach { scene ->
scene.triggers.filterIsInstance<SceneTrigger.Time>().forEach { trigger ->
scheduleNextTimeTrigger(profileId, scene, trigger)
}
}
return DomainResult.Success(Unit)
}
override suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit> {
workManager.cancelUniqueWork(workName(profileId))
workManager.cancelAllWorkByTag(timeWorkTag(profileId))
return DomainResult.Success(Unit)
}
Review

任务中断

replace 每次协调都会按标签取消全部定时任务,这也会停止已经处于运行状态的 worker。若用户在定时场景执行期间保存、启用或删除任意场景,当前执行可能在触发记录已被占用后遭到取消,导致动作只执行一部分且无法再次触发。应仅替换尚未运行的调度,或避免取消正在运行的工作。

**任务中断** `replace` 每次协调都会按标签取消全部定时任务,这也会停止已经处于运行状态的 worker。若用户在定时场景执行期间保存、启用或删除任意场景,当前执行可能在触发记录已被占用后遭到取消,导致动作只执行一部分且无法再次触发。应仅替换尚未运行的调度,或避免取消正在运行的工作。 <!-- pr-agent-dedup: ecf27d5946b4 --> <!-- pr-agent-key-issue-location: 8c2cba4abb4d -->
Review

任务残留

replace 只添加当前时间触发器的任务;只要该配置仍有任一自动场景,就不会取消已删除、禁用或改时的旧任务。反复编辑会累积旧 worker,它们到点后仍会校验会话并评估整个配置,产生不必要的后台 I/O 和设备请求。替换计划时应清理或比对旧时间任务。

**任务残留** `replace` 只添加当前时间触发器的任务;只要该配置仍有任一自动场景,就不会取消已删除、禁用或改时的旧任务。反复编辑会累积旧 worker,它们到点后仍会校验会话并评估整个配置,产生不必要的后台 I/O 和设备请求。替换计划时应清理或比对旧时间任务。 <!-- pr-agent-dedup: 20f0c54dcf7a --> <!-- pr-agent-key-issue-location: eb9820b6b300 -->
Review

任务泄漏

replace 只为当前时间触发器添加任务,却不取消已删除、禁用或修改的触发器对应的旧一次性任务。只要仍有其他自动场景,旧任务就会一直保留到执行;反复编辑时间触发器会累积后台任务,并在触发时产生多余的会话验证、设备读取和场景评估。

**任务泄漏** `replace` 只为当前时间触发器添加任务,却不取消已删除、禁用或修改的触发器对应的旧一次性任务。只要仍有其他自动场景,旧任务就会一直保留到执行;反复编辑时间触发器会累积后台任务,并在触发时产生多余的会话验证、设备读取和场景评估。 <!-- pr-agent-dedup: 1812827f9fe7 --> <!-- pr-agent-key-issue-location: 8f72625368dd -->
private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}"
private fun timeWorkName(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time, occurrenceKey: String) =
"automation-time:${profileId.value}:${scene.id.value}:${trigger.id.value}:$occurrenceKey"
private fun timeWorkTag(profileId: ProviderProfileId) = "automation-time:${profileId.value}"
companion object { const val KEY_PROFILE_ID = "profile_id" }
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time) {
val now = LocalDateTime.now()
val scheduledAt = nextOccurrence(trigger, now) ?: return
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), scheduledAt.atZone(ZoneId.systemDefault()).toInstant())
val scheduledAtInstant = scheduledAt.atZone(ZoneId.systemDefault()).toInstant()
val maximumExpiry = scheduledAtInstant.plus(MAXIMUM_OCCURRENCE_LATENESS)
val followingOccurrence = nextOccurrence(trigger, scheduledAt)
?.atZone(ZoneId.systemDefault())
?.toInstant()
?.minusMillis(1)
val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry
val occurrenceKey = "${scheduledAt.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}"
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
.setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS)
.addTag(timeWorkTag(profileId))
.setInputData(
androidx.work.Data.Builder()
.putString(KEY_PROFILE_ID, profileId.value)
.putString(KEY_SCENE_ID, scene.id.value)
.putString(KEY_TRIGGER_ID, trigger.id.value)
.putString(KEY_OCCURRENCE_KEY, occurrenceKey)
.putString(KEY_TRIGGER_DEFINITION, trigger.automationDefinition())
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli())
.putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli())
.build(),
)
.build()
workManager.enqueueUniqueWork(
timeWorkName(profileId, scene, trigger, occurrenceKey),
androidx.work.ExistingWorkPolicy.KEEP,
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"
const val KEY_SCENE_ID = "scene_id"
const val KEY_TRIGGER_ID = "trigger_id"
const val KEY_OCCURRENCE_KEY = "occurrence_key"
const val KEY_TRIGGER_DEFINITION = "trigger_definition"
const val KEY_SCHEDULED_AT_EPOCH_MILLIS = "scheduled_at_epoch_millis"
const val KEY_EXPIRES_AT_EPOCH_MILLIS = "expires_at_epoch_millis"
val MAXIMUM_OCCURRENCE_LATENESS: Duration = Duration.ofHours(24)
}
}
@@ -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,13 +41,40 @@ 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) {
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
} else null
val scheduled = listOf(now.toLocalDate(), now.toLocalDate().minusDays(1))
.map { date -> LocalDateTime.of(date, time) }
.firstOrNull { candidate ->
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
val latenessSeconds = Duration.between(candidate, now).seconds
dayMatches && latenessSeconds in 0..TIME_TRIGGER_GRACE_SECONDS
}
scheduled?.let { occurrence ->
TriggerEvaluation.Matched("${occurrence.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
}
}
is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) {
it.temperature
@@ -69,6 +97,7 @@ class SceneTriggerEvaluator {
result: DomainResult<TemperatureHumidityReading>?,
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 +107,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 +120,26 @@ 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
const val TIME_TRIGGER_GRACE_SECONDS = 20L * 60L
}
}
class SceneConditionEvaluator {
@@ -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<SceneId, Boolean>()
@@ -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) {
@@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -173,7 +174,23 @@ class SessionCoordinator(
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
val profile = profileStore.activeProfile()
?: 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 {
+2
View File
@@ -28,7 +28,9 @@ androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigationCompose" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigationCompose" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptiveNavigationSuite" }
androidx-window = { module = "androidx.window:window", version.ref = "window" }