feat(scene): complete automation runtime #11

Merged
KremeCN merged 9 commits from feat/scene-automation-runtime into main 2026-08-28 06:39:31 +08:00
4 changed files with 26 additions and 15 deletions
Showing only changes of commit 076a1daf5d - Show all commits
@@ -55,4 +55,7 @@ interface AutomationDao {
@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
}
@@ -57,14 +57,31 @@ class RoomSceneRepository(
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch"))
}
return runCatching {
Review

更新竞态

场景写入和旧触发状态清理是多个独立 DAO 操作,前台轮询或 Worker 可在两者之间读取新场景和旧状态。编辑一个保留相同 ID 的阈值触发器时,引擎可能基于混合状态执行并写入新边沿,随后清理又将其删除,导致下次轮询重复执行或丢失边沿。应将读取、场景更新和触发状态清理放入同一个 Room 事务。

**更新竞态** 场景写入和旧触发状态清理是多个独立 DAO 操作,前台轮询或 Worker 可在两者之间读取新场景和旧状态。编辑一个保留相同 ID 的阈值触发器时,引擎可能基于混合状态执行并写入新边沿,随后清理又将其删除,导致下次轮询重复执行或丢失边沿。应将读取、场景更新和触发状态清理放入同一个 Room 事务。 <!-- pr-agent-dedup: a4b099355e82 --> <!-- pr-agent-key-issue-location: c376684e873a -->
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")
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())
dao.deleteTriggerStates(profileId.value, scene.id.value)
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
@@ -93,15 +93,6 @@ class DefaultAutomationEngine(
}
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()) {
@@ -69,8 +69,8 @@ class SceneTriggerEvaluator {
.map { date -> LocalDateTime.of(date, time) }
.firstOrNull { candidate ->
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
val lateness = Duration.between(candidate, now).toMinutes()
dayMatches && lateness in 0..TIME_TRIGGER_GRACE_MINUTES
val latenessSeconds = Duration.between(candidate, now).seconds
dayMatches && latenessSeconds in 0..TIME_TRIGGER_GRACE_SECONDS
}
scheduled?.let { occurrence ->
TriggerEvaluation.Matched("${occurrence.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
@@ -138,7 +138,7 @@ class SceneTriggerEvaluator {
private companion object {
const val TEMPERATURE_HYSTERESIS = 0.5
const val HUMIDITY_HYSTERESIS = 2.0
const val TIME_TRIGGER_GRACE_MINUTES = 20L
const val TIME_TRIGGER_GRACE_SECONDS = 20L * 60L
}
}