feat(scene): complete automation runtime (#11)

## Summary

- persist scenes and trigger state in Room with a database migration and one-time default seeding
- execute enabled automatic scenes from real device readings with edge-triggered thresholds, conditions, hysteresis, and time occurrence deduplication
- schedule foreground polling and WorkManager background evaluation, including reconciliation after scene changes
- allow automatic commands to be run manually for testing while bypassing triggers and conditions
- send real system notifications with runtime permission handling and accurate failure reporting
- move Other Options & Appearance to the bottom of the command editor

## Verification

- ./gradlew.bat :android:app:assembleDebug --no-daemon
- installed and exercised on mulator-5554
- verified manual automatic-command execution and Activity records
- verified notification permission flow, posted notification, and successful Activity records

Automated tests were not run per the requested iteration workflow.

Reviewed-on: #11
Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #11.
This commit is contained in:
2026-08-28 06:39:31 +08:00
committed by KremeCN
parent 86b7e5849b
commit 4d4dd2893e
30 changed files with 1141 additions and 157 deletions
@@ -39,6 +39,9 @@ dependencies {
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
kapt(libs.androidx.hilt.compiler)
implementation(libs.androidx.lifecycle.process)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
@@ -23,4 +24,38 @@ interface AutomationDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long
@Query("SELECT * FROM scenes WHERE profileId = :profileId ORDER BY updatedAtEpochMillis, id")
fun observeScenes(profileId: String): Flow<List<SceneEntity>>
@Query("SELECT * FROM scenes WHERE profileId = :profileId ORDER BY updatedAtEpochMillis, id")
suspend fun getScenes(profileId: String): List<SceneEntity>
@Query("SELECT * FROM scenes WHERE profileId = :profileId AND id = :sceneId LIMIT 1")
suspend fun getScene(profileId: String, sceneId: String): SceneEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertScene(scene: SceneEntity)
@Query("DELETE FROM scenes WHERE profileId = :profileId AND id = :sceneId")
suspend fun deleteScene(profileId: String, sceneId: String): Int
@Query("SELECT * FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId AND triggerId = :triggerId LIMIT 1")
suspend fun getTriggerState(profileId: String, sceneId: String, triggerId: String): AutomationTriggerStateEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertTriggerState(state: AutomationTriggerStateEntity)
@Transaction
suspend fun transitionTriggerState(state: AutomationTriggerStateEntity): Boolean {
val previous = getTriggerState(state.profileId, state.sceneId, state.triggerId)?.matched ?: false
upsertTriggerState(state)
return !previous && state.matched
}
@Query("DELETE FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId")
suspend fun deleteTriggerStates(profileId: String, sceneId: String): Int
@Query("DELETE FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId AND triggerId = :triggerId")
suspend fun deleteTriggerState(profileId: String, sceneId: String, triggerId: String): Int
}
@@ -3,7 +3,16 @@ package com.flagship.abox.manager.automation.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false)
@Database(
entities = [
ActivityEventEntity::class,
AutomationOccurrenceEntity::class,
SceneEntity::class,
AutomationTriggerStateEntity::class,
],
version = 2,
exportSchema = false,
)
abstract class AutomationDatabase : RoomDatabase() {
abstract fun automationDao(): AutomationDao
}
@@ -31,3 +31,29 @@ data class AutomationOccurrenceEntity(
val triggerId: String,
val occurrenceKey: String,
)
@Entity(
tableName = "scenes",
primaryKeys = ["profileId", "id"],
indices = [Index(value = ["profileId", "type", "enabled"])],
)
data class SceneEntity(
val profileId: String,
val id: String,
val type: String,
val enabled: Boolean,
val document: String,
val updatedAtEpochMillis: Long,
)
@Entity(
tableName = "automation_trigger_state",
primaryKeys = ["profileId", "sceneId", "triggerId"],
)
data class AutomationTriggerStateEntity(
val profileId: String,
val sceneId: String,
val triggerId: String,
val matched: Boolean,
val updatedAtEpochMillis: Long,
)
@@ -0,0 +1,35 @@
package com.flagship.abox.manager.automation.data
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS scenes (
profileId TEXT NOT NULL,
id TEXT NOT NULL,
type TEXT NOT NULL,
enabled INTEGER NOT NULL,
document TEXT NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL,
PRIMARY KEY(profileId, id)
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_scenes_profileId_type_enabled ON scenes(profileId, type, enabled)")
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS automation_trigger_state (
profileId TEXT NOT NULL,
sceneId TEXT NOT NULL,
triggerId TEXT NOT NULL,
matched INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL,
PRIMARY KEY(profileId, sceneId, triggerId)
)
""".trimIndent(),
)
}
}
@@ -6,13 +6,23 @@ import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTriggerId
data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean)
interface AutomationRuntimeRepository {
suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult<Boolean>
suspend fun isMatched(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId): DomainResult<Boolean>
suspend fun transitionMatch(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, isMatched: Boolean, nowEpochMillis: Long): DomainResult<Boolean>
}
class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository {
override suspend fun isMatched(
profileId: ProviderProfileId,
sceneId: SceneId,
triggerId: SceneTriggerId,
): DomainResult<Boolean> = try {
DomainResult.Success(dao.getTriggerState(profileId.value, sceneId.value, triggerId.value)?.matched ?: false)
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
}
override suspend fun claimOccurrence(
profileId: ProviderProfileId,
sceneId: SceneId,
@@ -26,4 +36,25 @@ class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : Automati
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
}
override suspend fun transitionMatch(
profileId: ProviderProfileId,
sceneId: SceneId,
triggerId: SceneTriggerId,
isMatched: Boolean,
nowEpochMillis: Long,
): DomainResult<Boolean> = try {
val transitioned = dao.transitionTriggerState(
AutomationTriggerStateEntity(
profileId = profileId.value,
sceneId = sceneId.value,
triggerId = triggerId.value,
matched = isMatched,
updatedAtEpochMillis = nowEpochMillis,
),
)
DomainResult.Success(transitioned)
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
}
}
@@ -0,0 +1,125 @@
package com.flagship.abox.manager.automation.data
import android.content.Context
import androidx.room.withTransaction
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneConditionId
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SceneTriggerId
import com.flagship.abox.manager.domain.SocketPowerState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
class RoomSceneRepository(
context: Context,
private val database: AutomationDatabase,
) : SceneRepository {
private val dao = database.automationDao()
private val seedPreferences = context.getSharedPreferences("scene_seed_state", Context.MODE_PRIVATE)
override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> = flow {
ensureSeeded(profileId)
emitAll(dao.observeScenes(profileId.value)
.map<List<SceneEntity>, DomainResult<List<Scene>>> { entities -> DomainResult.Success(entities.map(SceneDocumentCodec::decode)) }
.catch { emit(DomainResult.Failure(DomainError.Unknown(detail = "Scene storage unavailable"))) })
}
override suspend fun getScene(profileId: ProviderProfileId, sceneId: SceneId): DomainResult<Scene?> = runCatching {
dao.getScene(profileId.value, sceneId.value)?.let(SceneDocumentCodec::decode)
}.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.Unknown(detail = "Scene storage unavailable")) })
override suspend fun createScene(profileId: ProviderProfileId, scene: Scene): DomainResult<Scene> = save(profileId, scene, requireExisting = false)
override suspend fun updateScene(profileId: ProviderProfileId, scene: Scene): DomainResult<Scene> = save(profileId, scene, requireExisting = true)
override suspend fun deleteScene(profileId: ProviderProfileId, sceneId: SceneId): DomainResult<Unit> = runCatching {
val deleted = dao.deleteScene(profileId.value, sceneId.value)
dao.deleteTriggerStates(profileId.value, sceneId.value)
if (deleted == 0) throw IllegalArgumentException("Scene not found")
}.fold({ DomainResult.Success(Unit) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) })
private suspend fun save(profileId: ProviderProfileId, scene: Scene, requireExisting: Boolean): DomainResult<Scene> {
if (scene.profileId != profileId || scene.actions.any { it.profileId != profileId }) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch"))
}
return runCatching {
database.withTransaction {
val existing = dao.getScene(profileId.value, scene.id.value)
if (requireExisting && existing == null) throw IllegalArgumentException("Scene not found")
if (!requireExisting && existing != null) throw IllegalArgumentException("Scene already exists")
dao.upsertScene(scene.toEntity())
existing?.let(SceneDocumentCodec::decode)?.triggers.orEmpty()
.filter { previous -> scene.triggers.none { current -> current.hasSameDefinitionAs(previous) } }
.forEach { changed ->
dao.deleteTriggerState(profileId.value, scene.id.value, changed.id.value)
}
}
scene
}.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) })
}
private fun SceneTrigger.hasSameDefinitionAs(other: SceneTrigger): Boolean = when {
this is SceneTrigger.Time && other is SceneTrigger.Time ->
id == other.id && timeOfDay == other.timeOfDay && daysOfWeek == other.daysOfWeek
this is SceneTrigger.Temperature && other is SceneTrigger.Temperature ->
id == other.id && deviceName == other.deviceName && operator == other.operator && thresholdCelsius == other.thresholdCelsius
this is SceneTrigger.Humidity && other is SceneTrigger.Humidity ->
id == other.id && deviceName == other.deviceName && operator == other.operator && thresholdPercent == other.thresholdPercent
this is SceneTrigger.DeviceState && other is SceneTrigger.DeviceState ->
id == other.id && deviceName == other.deviceName && expectedState == other.expectedState
else -> false
}
private suspend fun ensureSeeded(profileId: ProviderProfileId) {
val marker = "seed_v1_${profileId.value}"
if (seedPreferences.getBoolean(marker, false)) return
defaultScenes(profileId).forEach { scene ->
dao.upsertScene(scene.toEntity())
}
seedPreferences.edit().putBoolean(marker, true).apply()
}
private fun Scene.toEntity() = SceneEntity(profileId.value, id.value, type.name, enabled, SceneDocumentCodec.encode(this), System.currentTimeMillis())
private fun defaultScenes(profileId: ProviderProfileId): List<Scene> = listOf(
Scene(
SceneId("${profileId.value}:scene-arriving-home"), profileId, "回家模式", "开启 Zigbee 插座控制器",
AutomationType.MANUAL, true, SceneIcon.HOME, SceneColor("#4CAF50"),
actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:arrive:socket"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.ON)),
),
Scene(
SceneId("${profileId.value}:scene-leaving-home"), profileId, "离家模式", "关闭 Zigbee 插座控制器",
AutomationType.MANUAL, true, SceneIcon.AWAY, SceneColor("#FF8A65"),
actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:leave:socket"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.OFF)),
),
Scene(
SceneId("${profileId.value}:scene-temperature-alert"), profileId, "高温提醒", "室温高于 28°C 时发送提醒",
AutomationType.AUTOMATIC, true, SceneIcon.THERMOSTAT, SceneColor("#EF5350"),
triggers = listOf(SceneTrigger.Temperature(SceneTriggerId("${profileId.value}:temp-alert"), DeviceName("温湿度传感器"), ComparisonOperator.GREATER_THAN, 28.0, "温度高于 28°C")),
actions = listOf(SceneAction.Notification(SceneActionId("${profileId.value}:temp-alert:notification"), profileId, "温度提醒", "当前室温已高于 28°C")),
),
Scene(
SceneId("${profileId.value}:scene-cooling-auto"), profileId, "高温自动降温", "当室温高于 28°C 时开启插座",
AutomationType.AUTOMATIC, true, SceneIcon.THERMOSTAT, SceneColor("#26A69A"),
triggers = listOf(SceneTrigger.Temperature(SceneTriggerId("${profileId.value}:cooling-temp"), DeviceName("温湿度传感器"), ComparisonOperator.GREATER_THAN, 28.0, "温度高于 28°C")),
conditions = listOf(SceneCondition.SocketPower(SceneConditionId("${profileId.value}:cooling-socket"), DeviceName("Zigbee插座控制器"), SocketPowerState.OFF)),
actions = listOf(SceneAction.SocketPower(SceneActionId("${profileId.value}:cooling:on"), profileId, DeviceName("Zigbee插座控制器"), SocketPowerState.ON)),
),
)
}
@@ -0,0 +1,135 @@
package com.flagship.abox.manager.automation.data
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneConditionId
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SceneTriggerId
import com.flagship.abox.manager.domain.SocketPowerState
import org.json.JSONArray
import org.json.JSONObject
internal object SceneDocumentCodec {
fun encode(scene: Scene): String = JSONObject()
.put("name", scene.name)
.put("description", scene.description)
.put("type", scene.type.name)
.put("enabled", scene.enabled)
.put("icon", scene.icon.name)
.put("color", scene.color.hex)
.put("strategy", scene.strategy.name)
.put("concurrency", scene.concurrencyMode.name)
.put("logging", scene.enableLogging)
.put("triggers", JSONArray().apply { scene.triggers.forEach { put(encodeTrigger(it)) } })
.put("conditions", JSONArray().apply { scene.conditions.forEach { put(encodeCondition(it)) } })
.put("actions", JSONArray().apply { scene.actions.forEach { put(encodeAction(it)) } })
.toString()
fun decode(entity: SceneEntity): Scene {
val document = JSONObject(entity.document)
val profileId = ProviderProfileId(entity.profileId)
return Scene(
id = SceneId(entity.id),
profileId = profileId,
name = document.getString("name"),
description = document.optString("description"),
type = enumValue(document.getString("type"), AutomationType.MANUAL),
enabled = document.optBoolean("enabled", true),
icon = enumValue(document.optString("icon"), SceneIcon.HOME),
color = SceneColor(document.optString("color", "#3F51B5")),
triggers = document.optJSONArray("triggers").objects().map(::decodeTrigger),
conditions = document.optJSONArray("conditions").objects().map(::decodeCondition),
actions = document.optJSONArray("actions").objects().map { decodeAction(it, profileId) },
strategy = enumValue(document.optString("strategy"), ExecutionStrategy.CONTINUE_ON_ERROR),
concurrencyMode = enumValue(document.optString("concurrency"), ConcurrencyMode.SEQUENTIAL),
enableLogging = document.optBoolean("logging", true),
)
}
private fun encodeTrigger(trigger: SceneTrigger): JSONObject = JSONObject().put("id", trigger.id.value).apply {
when (trigger) {
is SceneTrigger.Time -> put("kind", "time").put("time", trigger.timeOfDay)
.put("days", JSONArray(trigger.daysOfWeek.sorted())).put("label", trigger.label)
is SceneTrigger.Temperature -> put("kind", "temperature").put("device", trigger.deviceName.value)
.put("operator", trigger.operator.name).put("threshold", trigger.thresholdCelsius).put("label", trigger.label)
is SceneTrigger.Humidity -> put("kind", "humidity").put("device", trigger.deviceName.value)
.put("operator", trigger.operator.name).put("threshold", trigger.thresholdPercent).put("label", trigger.label)
is SceneTrigger.DeviceState -> put("kind", "device_state").put("device", trigger.deviceName.value)
.put("state", trigger.expectedState.name).put("label", trigger.label)
}
}
private fun decodeTrigger(value: JSONObject): SceneTrigger {
val id = SceneTriggerId(value.getString("id"))
return when (value.getString("kind")) {
"time" -> SceneTrigger.Time(id, value.getString("time"), value.optJSONArray("days").ints().toSet(), value.optString("label"))
"temperature" -> SceneTrigger.Temperature(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"), value.optString("label"))
"humidity" -> SceneTrigger.Humidity(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"), value.optString("label"))
"device_state" -> SceneTrigger.DeviceState(id, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF), value.optString("label"))
else -> error("Unsupported trigger kind")
}
}
private fun encodeCondition(condition: SceneCondition): JSONObject = JSONObject().put("id", condition.id.value).apply {
when (condition) {
is SceneCondition.Temperature -> put("kind", "temperature").put("device", condition.deviceName.value)
.put("operator", condition.operator.name).put("threshold", condition.thresholdCelsius)
is SceneCondition.Humidity -> put("kind", "humidity").put("device", condition.deviceName.value)
.put("operator", condition.operator.name).put("threshold", condition.thresholdPercent)
is SceneCondition.SocketPower -> put("kind", "socket").put("device", condition.deviceName.value)
.put("state", condition.expectedState.name)
is SceneCondition.TimeRange -> put("kind", "time_range").put("start", condition.startTime).put("end", condition.endTime)
}
}
private fun decodeCondition(value: JSONObject): SceneCondition {
val id = SceneConditionId(value.getString("id"))
return when (value.getString("kind")) {
"temperature" -> SceneCondition.Temperature(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"))
"humidity" -> SceneCondition.Humidity(id, DeviceName(value.getString("device")), enumValue(value.getString("operator"), ComparisonOperator.GREATER_THAN), value.getDouble("threshold"))
"socket" -> SceneCondition.SocketPower(id, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF))
"time_range" -> SceneCondition.TimeRange(id, value.getString("start"), value.getString("end"))
else -> error("Unsupported condition kind")
}
}
private fun encodeAction(action: SceneAction): JSONObject = JSONObject().put("id", action.id.value).apply {
when (action) {
is SceneAction.SocketPower -> put("kind", "socket").put("device", action.deviceName.value).put("state", action.powerState.name)
is SceneAction.VirtualRemoteButton -> put("kind", "remote").put("remoteId", action.remoteId)
.put("remoteName", action.remoteDisplayName).put("buttonId", action.buttonId).put("buttonName", action.buttonDisplayName)
is SceneAction.InfraredKey -> put("kind", "infrared").put("device", action.deviceName.value).put("key", action.key)
is SceneAction.Delay -> put("kind", "delay").put("seconds", action.durationSeconds)
is SceneAction.Notification -> put("kind", "notification").put("title", action.title).put("message", action.message)
}
}
private fun decodeAction(value: JSONObject, profileId: ProviderProfileId): SceneAction {
val id = SceneActionId(value.getString("id"))
return when (value.getString("kind")) {
"socket" -> SceneAction.SocketPower(id, profileId, DeviceName(value.getString("device")), enumValue(value.getString("state"), SocketPowerState.OFF))
"remote" -> SceneAction.VirtualRemoteButton(id, profileId, value.getString("remoteId"), value.optString("remoteName"), value.getString("buttonId"), value.optString("buttonName"))
"infrared" -> SceneAction.InfraredKey(id, profileId, DeviceName(value.getString("device")), value.getString("key"))
"delay" -> SceneAction.Delay(id, profileId, value.getInt("seconds"))
"notification" -> SceneAction.Notification(id, profileId, value.getString("title"), value.optString("message"))
else -> error("Unsupported action kind")
}
}
private inline fun <reified T : Enum<T>> enumValue(value: String, fallback: T): T =
enumValues<T>().firstOrNull { it.name == value } ?: fallback
private fun JSONArray?.objects(): List<JSONObject> = if (this == null) emptyList() else List(length()) { getJSONObject(it) }
private fun JSONArray?.ints(): List<Int> = if (this == null) emptyList() else List(length()) { getInt(it) }
}
@@ -6,9 +6,24 @@ import com.flagship.abox.manager.automation.data.AutomationDatabase
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
import com.flagship.abox.manager.automation.data.RoomActivityRepository
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
import com.flagship.abox.manager.automation.data.RoomSceneRepository
import com.flagship.abox.manager.automation.data.MIGRATION_1_2
import com.flagship.abox.manager.automation.runtime.AutomationBootstrapper
import com.flagship.abox.manager.automation.runtime.AutomationEngine
import com.flagship.abox.manager.automation.runtime.AutomationScheduleStore
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
import com.flagship.abox.manager.automation.runtime.DefaultAutomationEngine
import com.flagship.abox.manager.automation.runtime.ProfileAutomationScheduler
import com.flagship.abox.manager.automation.runtime.WorkManagerAutomationScheduleStore
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway
import dagger.Module
import dagger.Provides
@@ -26,7 +41,9 @@ object AutomationModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase =
Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build()
Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db")
.addMigrations(MIGRATION_1_2)
.build()
@Provides
@Singleton
@@ -41,6 +58,57 @@ object AutomationModule {
@Singleton
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
@Provides
@Singleton
fun provideSceneRepository(
@ApplicationContext context: Context,
database: AutomationDatabase,
): SceneRepository = RoomSceneRepository(context, database)
@Provides
@Singleton
fun provideAutomationEngine(
sceneRepository: SceneRepository,
runtimeRepository: AutomationRuntimeRepository,
temperatureHumidityGateway: TemperatureHumidityGateway,
socketGateway: SocketGateway,
sceneExecutor: SceneExecutor,
): AutomationEngine = DefaultAutomationEngine(
sceneRepository,
runtimeRepository,
temperatureHumidityGateway,
socketGateway,
sceneExecutor,
)
@Provides
@Singleton
fun provideAutomationScheduleStore(@ApplicationContext context: Context): AutomationScheduleStore =
WorkManagerAutomationScheduleStore(context)
@Provides
@Singleton
fun provideAutomationScheduler(
profileStore: ProviderProfileStore,
sessionCoordinator: SessionCoordinator,
sceneRepository: SceneRepository,
scheduleStore: AutomationScheduleStore,
): AutomationScheduler = ProfileAutomationScheduler(profileStore, sessionCoordinator, sceneRepository, scheduleStore)
@Provides
@Singleton
fun provideAutomationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun provideAutomationBootstrapper(
profileStore: ProviderProfileStore,
sessionCoordinator: SessionCoordinator,
scheduler: AutomationScheduler,
engine: AutomationEngine,
scope: CoroutineScope,
): AutomationBootstrapper = AutomationBootstrapper(profileStore, sessionCoordinator, scheduler, engine, scope)
@Provides
@Singleton
fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway =
@@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.annotation.SuppressLint
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@@ -13,37 +14,54 @@ import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.UUID
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<Unit> {
if (android.os.Build.VERSION.SDK_INT >= 33 &&
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
return notificationUnavailable()
}
val notificationManager = NotificationManagerCompat.from(context)
if (!notificationManager.areNotificationsEnabled()) return notificationUnavailable()
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT),
)
if (manager.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE) {
return notificationUnavailable()
}
}
return try {
@SuppressLint("MissingPermission")
fun postNotification() {
NotificationManagerCompat.from(context).notify(
profileId.value.hashCode(),
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(message)
.setAutoCancel(true)
.build(),
notificationManager.notify(
UUID.randomUUID().hashCode(),
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setAutoCancel(true)
.build(),
)
}
postNotification()
DomainResult.Success(Unit)
} catch (_: SecurityException) {
DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
notificationUnavailable()
}
}
private companion object { const val CHANNEL_ID = "automation_events" }
private fun notificationUnavailable(): DomainResult.Failure =
DomainResult.Failure(DomainError.OperationFailed(detail = "Notifications are not allowed"))
private companion object {
const val CHANNEL_ID = "automation_events"
}
}
@@ -1,25 +1,78 @@
package com.flagship.abox.manager.automation.runtime
import com.flagship.abox.manager.domain.ProviderProfileStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
class AutomationBootstrapper(
private val profileStore: ProviderProfileStore,
private val sessionCoordinator: SessionCoordinator,
private val scheduler: AutomationScheduler,
private val engine: AutomationEngine,
private val scope: CoroutineScope,
) {
) : DefaultLifecycleObserver {
private var previousProfileId: ProviderProfileId? = null
private val isForeground = MutableStateFlow(false)
fun start() {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
scope.launch {
profileStore.observeActiveProfile().collectLatest { active ->
combine(
profileStore.observeActiveProfile(),
sessionCoordinator.observeActiveSession(),
) { active, session -> active to session }
.collectLatest { (active, session) ->
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
previousProfileId = active?.id
scheduler.reconcileActiveProfile()
if (active != null && session?.profileId == active.id) {
var scheduleReady = false
while (true) {
isForeground.first { it }
when (val validity = sessionCoordinator.validateSession(active.id)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
if (!scheduleReady) {
scheduleReady = scheduler.reconcile(active.id) is DomainResult.Success
}
if (scheduleReady) {
try {
engine.evaluate(active.id)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {}
}
} else {
scheduler.cancel(active.id)
scheduleReady = false
}
is DomainResult.Failure -> Unit
}
if (isForeground.value) delay(60_000L)
}
} else if (active != null) {
scheduler.cancel(active.id)
}
}
}
}
override fun onStart(owner: LifecycleOwner) {
isForeground.value = true
}
override fun onStop(owner: LifecycleOwner) {
isForeground.value = false
}
}
@@ -0,0 +1,199 @@
package com.flagship.abox.manager.automation.runtime
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
import com.flagship.abox.manager.domain.AutomationSnapshot
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ConditionsEvaluation
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneConditionEvaluator
import com.flagship.abox.manager.domain.SceneExecutionId
import com.flagship.abox.manager.domain.SceneExecutionSource
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SceneTriggerEvaluator
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.TriggerEvaluation
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.UUID
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface AutomationEngine {
suspend fun evaluate(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence? = null,
): DomainResult<Int>
}
data class ScheduledAutomationOccurrence(
val sceneId: String,
val triggerId: String,
val occurrenceKey: String,
val triggerDefinition: String,
val scheduledAtEpochMillis: Long,
val expiresAtEpochMillis: Long,
)
class DefaultAutomationEngine(
private val sceneRepository: SceneRepository,
private val runtimeRepository: AutomationRuntimeRepository,
private val temperatureHumidityGateway: TemperatureHumidityGateway,
private val socketGateway: SocketGateway,
private val sceneExecutor: SceneExecutor,
private val triggerEvaluator: SceneTriggerEvaluator = SceneTriggerEvaluator(),
private val conditionEvaluator: SceneConditionEvaluator = SceneConditionEvaluator(),
private val nowMillis: () -> Long = System::currentTimeMillis,
) : AutomationEngine {
private val evaluationMutex = Mutex()
override suspend fun evaluate(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<Int> {
val scenesToExecute = when (val prepared = evaluationMutex.withLock { prepareExecutions(profileId, scheduledOccurrence) }) {
is DomainResult.Failure -> return prepared
is DomainResult.Success -> prepared.value
}
var executed = 0
for (scene in scenesToExecute) {
sceneExecutor.execute(
scene = scene,
executionId = SceneExecutionId("auto-${UUID.randomUUID()}"),
source = SceneExecutionSource.AUTOMATION,
).collect { update ->
if (update is com.flagship.abox.manager.domain.SceneExecutionUpdate.Completed) executed++
}
}
return DomainResult.Success(executed)
}
private suspend fun prepareExecutions(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<List<Scene>> {
val scenes = when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> return result
is DomainResult.Success -> result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }
}
if (scenes.isEmpty()) return DomainResult.Success(emptyList())
val snapshot = collectSnapshot(profileId, scenes)
val now = LocalDateTime.ofInstant(Instant.ofEpochMilli(snapshot.observedAtEpochMillis), ZoneId.systemDefault())
val executions = mutableListOf<Scene>()
for (scene in scenes) {
var newlyMatched = false
val newlyMatchedStateTriggers = mutableListOf<SceneTrigger>()
val matchedTimeTriggers = mutableListOf<Pair<SceneTrigger.Time, String>>()
for (trigger in scene.triggers) {
val wasMatched = if (trigger is SceneTrigger.Time) {
false
} else {
when (val state = runtimeRepository.isMatched(profileId, scene.id, trigger.id)) {
is DomainResult.Failure -> return state
is DomainResult.Success -> state.value
}
}
val evaluation = if (
trigger is SceneTrigger.Time &&
scheduledOccurrence?.sceneId == scene.id.value &&
scheduledOccurrence.triggerId == trigger.id.value &&
scheduledOccurrence.triggerDefinition == trigger.automationDefinition() &&
snapshot.observedAtEpochMillis in scheduledOccurrence.scheduledAtEpochMillis..scheduledOccurrence.expiresAtEpochMillis
) {
TriggerEvaluation.Matched(scheduledOccurrence.occurrenceKey)
} else {
triggerEvaluator.evaluate(trigger, snapshot, now, wasMatched)
}
val isMatched = evaluation is TriggerEvaluation.Matched
if (trigger is SceneTrigger.Time && evaluation is TriggerEvaluation.Matched) {
newlyMatched = true
matchedTimeTriggers += trigger to evaluation.occurrenceKey
} else if (trigger !is SceneTrigger.Time && evaluation !is TriggerEvaluation.Unavailable) {
when (val transition = runtimeRepository.transitionMatch(
profileId,
scene.id,
trigger.id,
isMatched,
snapshot.observedAtEpochMillis,
)) {
is DomainResult.Failure -> return transition
is DomainResult.Success -> if (transition.value) {
newlyMatched = true
newlyMatchedStateTriggers += trigger
}
}
}
}
if (!newlyMatched) continue
when (conditionEvaluator.evaluate(scene.conditions, snapshot, now)) {
is ConditionsEvaluation.Unavailable -> continue
is ConditionsEvaluation.NotSatisfied -> {
for ((trigger, occurrenceKey) in matchedTimeTriggers) {
when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) {
is DomainResult.Failure -> return claim
is DomainResult.Success -> Unit
}
}
continue
}
ConditionsEvaluation.Satisfied -> Unit
}
var executionCount = if (newlyMatchedStateTriggers.isEmpty()) 0 else 1
if (matchedTimeTriggers.isNotEmpty()) {
for ((trigger, occurrenceKey) in matchedTimeTriggers) {
when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) {
is DomainResult.Failure -> return claim
is DomainResult.Success -> if (claim.value) executionCount++
}
}
}
if (executionCount == 0) continue
repeat(executionCount) {
executions += scene
}
}
return DomainResult.Success(executions)
}
private suspend fun collectSnapshot(profileId: ProviderProfileId, scenes: List<Scene>): AutomationSnapshot {
val readingDevices = linkedSetOf<DeviceName>()
val socketDevices = linkedSetOf<DeviceName>()
scenes.forEach { scene ->
scene.triggers.forEach { trigger ->
when (trigger) {
is SceneTrigger.Temperature -> readingDevices += trigger.deviceName
is SceneTrigger.Humidity -> readingDevices += trigger.deviceName
is SceneTrigger.DeviceState -> socketDevices += trigger.deviceName
is SceneTrigger.Time -> Unit
}
}
scene.conditions.forEach { condition ->
when (condition) {
is SceneCondition.Temperature -> readingDevices += condition.deviceName
is SceneCondition.Humidity -> readingDevices += condition.deviceName
is SceneCondition.SocketPower -> socketDevices += condition.deviceName
is SceneCondition.TimeRange -> Unit
}
}
}
return AutomationSnapshot(
observedAtEpochMillis = nowMillis(),
readings = readingDevices.associateWith { temperatureHumidityGateway.getReading(profileId, it) },
socketStatuses = socketDevices.associateWith { socketGateway.getStatus(profileId, it) },
)
}
}
internal fun SceneTrigger.Time.automationDefinition(): String =
listOf(timeOfDay, daysOfWeek.sorted().joinToString(",")).joinToString("|")
@@ -1,10 +1,57 @@
package com.flagship.abox.manager.automation.runtime
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
class AutomationPollWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result =
if (inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID).isNullOrBlank()) Result.failure() else Result.success()
@HiltWorker
class AutomationPollWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val profileStore: ProviderProfileStore,
private val sessionCoordinator: SessionCoordinator,
private val automationEngine: AutomationEngine,
private val automationScheduler: AutomationScheduler,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val rawProfileId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID)
?: return Result.failure()
val profileId = ProviderProfileId(rawProfileId)
if (profileStore.activeProfile()?.id != profileId) return Result.success()
sessionCoordinator.bootstrap()
return when (val validity = sessionCoordinator.validateSession(profileId)) {
is DomainResult.Failure -> Result.retry()
is DomainResult.Success -> if (validity.value != SessionValidity.VALID) {
automationScheduler.cancel(profileId)
Result.success()
} else {
val scheduledOccurrence = inputData.getString(WorkManagerAutomationScheduleStore.KEY_OCCURRENCE_KEY)?.let { key ->
val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID)
val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID)
val triggerDefinition = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_DEFINITION)
val scheduledAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_SCHEDULED_AT_EPOCH_MILLIS, -1L)
val expiresAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_EXPIRES_AT_EPOCH_MILLIS, -1L)
if (sceneId != null && triggerId != null && triggerDefinition != null && scheduledAt >= 0L && expiresAt >= scheduledAt) {
ScheduledAutomationOccurrence(sceneId, triggerId, key, triggerDefinition, scheduledAt, expiresAt)
} else null
}
when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
is DomainResult.Success -> {
automationScheduler.reconcile(profileId)
Result.success()
}
is DomainResult.Failure -> Result.retry()
}
}
}
}
}
@@ -7,7 +7,7 @@ import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import kotlinx.coroutines.flow.first
@@ -24,7 +24,7 @@ interface AutomationScheduleStore {
class ProfileAutomationScheduler(
private val profileStore: ProviderProfileStore,
private val sessionGateway: SessionGateway,
private val sessionCoordinator: SessionCoordinator,
private val sceneRepository: SceneRepository,
private val scheduleStore: AutomationScheduleStore,
) : AutomationScheduler {
@@ -36,7 +36,7 @@ class ProfileAutomationScheduler(
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
val active = profileStore.activeProfile()
if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))
return when (val validity = sessionGateway.validateSession(profileId)) {
return when (val validity = sessionCoordinator.validateSession(profileId)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> result
@@ -3,11 +3,17 @@ package com.flagship.abox.manager.automation.runtime
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import java.util.concurrent.TimeUnit
import java.time.Duration
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import com.flagship.abox.manager.domain.SceneTrigger
class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore {
private val workManager = WorkManager.getInstance(context)
@@ -18,15 +24,79 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
.build()
workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request)
scenes.forEach { scene ->
scene.triggers.filterIsInstance<SceneTrigger.Time>().forEach { trigger ->
scheduleNextTimeTrigger(profileId, scene, trigger)
}
}
return DomainResult.Success(Unit)
}
override suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit> {
workManager.cancelUniqueWork(workName(profileId))
workManager.cancelAllWorkByTag(timeWorkTag(profileId))
return DomainResult.Success(Unit)
}
private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}"
private fun timeWorkName(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time, occurrenceKey: String) =
"automation-time:${profileId.value}:${scene.id.value}:${trigger.id.value}:$occurrenceKey"
private fun timeWorkTag(profileId: ProviderProfileId) = "automation-time:${profileId.value}"
companion object { const val KEY_PROFILE_ID = "profile_id" }
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scene: Scene, trigger: SceneTrigger.Time) {
val now = LocalDateTime.now()
val scheduledAt = nextOccurrence(trigger, now) ?: return
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), scheduledAt.atZone(ZoneId.systemDefault()).toInstant())
val scheduledAtInstant = scheduledAt.atZone(ZoneId.systemDefault()).toInstant()
val maximumExpiry = scheduledAtInstant.plus(MAXIMUM_OCCURRENCE_LATENESS)
val followingOccurrence = nextOccurrence(trigger, scheduledAt)
?.atZone(ZoneId.systemDefault())
?.toInstant()
?.minusMillis(1)
val expiresAt = listOfNotNull(maximumExpiry, followingOccurrence).minOrNull() ?: maximumExpiry
val occurrenceKey = "${scheduledAt.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}"
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
.setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS)
.addTag(timeWorkTag(profileId))
.setInputData(
androidx.work.Data.Builder()
.putString(KEY_PROFILE_ID, profileId.value)
.putString(KEY_SCENE_ID, scene.id.value)
.putString(KEY_TRIGGER_ID, trigger.id.value)
.putString(KEY_OCCURRENCE_KEY, occurrenceKey)
.putString(KEY_TRIGGER_DEFINITION, trigger.automationDefinition())
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, scheduledAtInstant.toEpochMilli())
.putLong(KEY_EXPIRES_AT_EPOCH_MILLIS, expiresAt.toEpochMilli())
.build(),
)
.build()
workManager.enqueueUniqueWork(
timeWorkName(profileId, scene, trigger, occurrenceKey),
androidx.work.ExistingWorkPolicy.KEEP,
request,
)
}
private fun nextOccurrence(trigger: SceneTrigger.Time, now: LocalDateTime): LocalDateTime? {
val time = runCatching { LocalTime.parse(trigger.timeOfDay) }.getOrNull() ?: return null
for (offset in 0..7) {
val date = now.toLocalDate().plusDays(offset.toLong())
val candidate = LocalDateTime.of(date, time)
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
if (dayMatches && candidate.isAfter(now)) return candidate
}
return null
}
companion object {
const val KEY_PROFILE_ID = "profile_id"
const val KEY_SCENE_ID = "scene_id"
const val KEY_TRIGGER_ID = "trigger_id"
const val KEY_OCCURRENCE_KEY = "occurrence_key"
const val KEY_TRIGGER_DEFINITION = "trigger_definition"
const val KEY_SCHEDULED_AT_EPOCH_MILLIS = "scheduled_at_epoch_millis"
const val KEY_EXPIRES_AT_EPOCH_MILLIS = "expires_at_epoch_millis"
val MAXIMUM_OCCURRENCE_LATENESS: Duration = Duration.ofHours(24)
}
}