feat(scene): complete automation runtime
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+87
-87
@@ -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)
|
||||
|
||||
+6
-1
@@ -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,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)
|
||||
}
|
||||
|
||||
+14
-4
@@ -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)
|
||||
|
||||
+32
@@ -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
|
||||
}
|
||||
|
||||
+10
-1
@@ -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
|
||||
}
|
||||
|
||||
+26
@@ -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,
|
||||
)
|
||||
|
||||
+35
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+33
-2
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
+135
@@ -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) }
|
||||
}
|
||||
+69
-1
@@ -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 =
|
||||
|
||||
+32
-15
@@ -6,6 +6,7 @@ import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -16,34 +17,50 @@ import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
|
||||
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
|
||||
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<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(
|
||||
profileId.value.hashCode(),
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setAutoCancel(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
postNotification()
|
||||
DomainResult.Success(Unit)
|
||||
} catch (_: SecurityException) {
|
||||
DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
|
||||
notificationUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object { const val CHANNEL_ID = "automation_events" }
|
||||
private fun notificationUnavailable(): DomainResult.Failure =
|
||||
DomainResult.Failure(DomainError.OperationFailed(detail = "Notifications are not allowed"))
|
||||
|
||||
private companion object {
|
||||
const val CHANNEL_ID = "automation_events"
|
||||
}
|
||||
}
|
||||
|
||||
+23
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+39
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+36
@@ -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" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user