feat(scene): complete automation runtime

This commit is contained in:
2026-08-28 01:29:48 +08:00
parent 86b7e5849b
commit 8e824b50da
28 changed files with 969 additions and 147 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,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"
}
}
@@ -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,
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,7 +401,6 @@ private fun SceneCard(
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (scene.type == AutomationType.MANUAL) {
FilledTonalButton(
onClick = onQuickExecute,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
@@ -414,7 +412,6 @@ private fun SceneCard(
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,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)
@@ -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<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
}
@@ -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,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<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 {
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<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.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 =
@@ -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,24 +17,35 @@ 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<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)
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(
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(),
)
@@ -41,9 +53,14 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat
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,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)
}
}
}
}
@@ -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<Int>
}
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<Int> = evaluationMutex.withLock {
evaluateLocked(profileId)
}
private suspend fun evaluateLocked(profileId: ProviderProfileId): DomainResult<Int> {
val scenes = when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> return result
is DomainResult.Success -> result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }
}
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<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 = 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<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) },
)
}
}
@@ -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()
}
}
}
}
}
@@ -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.validateActiveSession()) {
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,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<Unit> {
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<Scene>) {
val now = LocalDateTime.now()
val candidates = scenes.flatMap { scene -> scene.triggers.filterIsInstance<SceneTrigger.Time>() }
.mapNotNull { trigger -> nextOccurrence(trigger, now) }
val next = candidates.minOrNull() ?: run {
workManager.cancelUniqueWork(timeWorkName(profileId))
return
}
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), next.atZone(ZoneId.systemDefault()).toInstant())
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
.setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS)
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
.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" }
}
@@ -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<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 +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 {
@@ -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 -> {
if (notificationGateway == null) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = "Notification gateway is unavailable"),
)
} else {
toExecutionResult(action, notificationGateway.show(profileId, action.title, action.message))
}
}
}
} catch (e: CancellationException) {