feat(scene): complete automation runtime #11
+3
@@ -55,4 +55,7 @@ interface AutomationDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId")
|
@Query("DELETE FROM automation_trigger_state WHERE profileId = :profileId AND sceneId = :sceneId")
|
||||||
suspend fun deleteTriggerStates(profileId: String, sceneId: String): Int
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -57,14 +57,31 @@ class RoomSceneRepository(
|
|||||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch"))
|
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch"))
|
||||||
}
|
}
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
|
|||||||
if (requireExisting && dao.getScene(profileId.value, scene.id.value) == null) throw IllegalArgumentException("Scene not found")
|
val existing = dao.getScene(profileId.value, scene.id.value)
|
||||||
if (!requireExisting && dao.getScene(profileId.value, scene.id.value) != null) throw IllegalArgumentException("Scene already exists")
|
if (requireExisting && existing == null) throw IllegalArgumentException("Scene not found")
|
||||||
|
if (!requireExisting && existing != null) throw IllegalArgumentException("Scene already exists")
|
||||||
dao.upsertScene(scene.toEntity())
|
dao.upsertScene(scene.toEntity())
|
||||||
dao.deleteTriggerStates(profileId.value, scene.id.value)
|
existing?.let(SceneDocumentCodec::decode)?.triggers.orEmpty()
|
||||||
|
pr-agent-bot
commented
意外重触发 每次更新场景都会无条件删除全部触发器状态。用户仅修改名称、图标或动作时,如果某个阈值当前仍处于匹配状态,下一轮评估会再次识别为从未匹配到匹配并重复执行自动化。应只清理已删除或定义发生变化的触发器状态,并保留未修改触发器的状态。 **意外重触发**
每次更新场景都会无条件删除全部触发器状态。用户仅修改名称、图标或动作时,如果某个阈值当前仍处于匹配状态,下一轮评估会再次识别为从未匹配到匹配并重复执行自动化。应只清理已删除或定义发生变化的触发器状态,并保留未修改触发器的状态。
<!-- pr-agent-dedup: 084401ee6ca9 -->
<!-- pr-agent-key-issue-location: f6d7241cf954 -->
|
|||||||
|
.filter { previous -> scene.triggers.none { current -> current.hasSameDefinitionAs(previous) } }
|
||||||
|
.forEach { changed ->
|
||||||
|
dao.deleteTriggerState(profileId.value, scene.id.value, changed.id.value)
|
||||||
|
}
|
||||||
scene
|
scene
|
||||||
}.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) })
|
}.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) {
|
private suspend fun ensureSeeded(profileId: ProviderProfileId) {
|
||||||
val marker = "seed_v1_${profileId.value}"
|
val marker = "seed_v1_${profileId.value}"
|
||||||
if (seedPreferences.getBoolean(marker, false)) return
|
if (seedPreferences.getBoolean(marker, false)) return
|
||||||
|
|||||||
-9
@@ -93,15 +93,6 @@ class DefaultAutomationEngine(
|
|||||||
}
|
}
|
||||||
if (!newlyMatched) continue
|
if (!newlyMatched) continue
|
||||||
if (conditionEvaluator.evaluate(scene.conditions, snapshot, now) !is ConditionsEvaluation.Satisfied) {
|
if (conditionEvaluator.evaluate(scene.conditions, snapshot, now) !is ConditionsEvaluation.Satisfied) {
|
||||||
|
pr-agent-bot
commented
边沿丢失 阈值刚越界但条件不满足时,这里会把刚写入的匹配状态重置为 **边沿丢失**
阈值刚越界但条件不满足时,这里会把刚写入的匹配状态重置为 `false`。如果阈值之后一直保持越界,只要条件在后续轮询中变为满足,场景就会在没有新阈值边沿的情况下执行,破坏边沿触发语义。条件失败后应保留已匹配状态,或显式建模待处理事件。
<!-- pr-agent-dedup: a7138467d563 -->
<!-- pr-agent-key-issue-location: 5ff8a986e04d -->
|
|||||||
newlyMatchedStateTriggers.forEach { trigger ->
|
|
||||||
runtimeRepository.transitionMatch(
|
|
||||||
profileId,
|
|
||||||
scene.id,
|
|
||||||
trigger.id,
|
|
||||||
false,
|
|
||||||
snapshot.observedAtEpochMillis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (matchedTimeTriggers.isNotEmpty()) {
|
if (matchedTimeTriggers.isNotEmpty()) {
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ class SceneTriggerEvaluator {
|
|||||||
.map { date -> LocalDateTime.of(date, time) }
|
.map { date -> LocalDateTime.of(date, time) }
|
||||||
|
pr-agent-bot
commented
跨日漏触发 时间触发器始终基于当前日期构造计划时间,因此午夜后执行时无法匹配前一天的计划。例如周一 23:50 的任务被 WorkManager 延迟到周二 00:05 后,计划时间会被计算为周二 23:50,并且星期也按周二判断,导致本应处于宽限窗口内的任务永久漏执行。 **跨日漏触发**
时间触发器始终基于当前日期构造计划时间,因此午夜后执行时无法匹配前一天的计划。例如周一 23:50 的任务被 WorkManager 延迟到周二 00:05 后,计划时间会被计算为周二 23:50,并且星期也按周二判断,导致本应处于宽限窗口内的任务永久漏执行。
<!-- pr-agent-dedup: 24d013e675d0 -->
<!-- pr-agent-key-issue-location: 9f6d81f0caab -->
|
|||||||
.firstOrNull { candidate ->
|
.firstOrNull { candidate ->
|
||||||
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
|
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
|
||||||
val lateness = Duration.between(candidate, now).toMinutes()
|
val latenessSeconds = Duration.between(candidate, now).seconds
|
||||||
|
pr-agent-bot
commented
提前触发 当轮询发生在计划时间前不足一分钟时,负的 **提前触发**
当轮询发生在计划时间前不足一分钟时,负的 `Duration` 经 `toMinutes()` 向零截断为 0,仍会通过时间窗口判断。例如 09:59:30 会提前执行 10:00 的规则并占用该 occurrence,真正到点时不再执行。应先排除负时长或改用秒级比较。
<!-- pr-agent-dedup: 3629966bd0b7 -->
<!-- pr-agent-key-issue-location: d1ec98b89700 -->
|
|||||||
dayMatches && lateness in 0..TIME_TRIGGER_GRACE_MINUTES
|
dayMatches && latenessSeconds in 0..TIME_TRIGGER_GRACE_SECONDS
|
||||||
}
|
}
|
||||||
scheduled?.let { occurrence ->
|
scheduled?.let { occurrence ->
|
||||||
TriggerEvaluation.Matched("${occurrence.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
|
TriggerEvaluation.Matched("${occurrence.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
|
||||||
@@ -138,7 +138,7 @@ class SceneTriggerEvaluator {
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TEMPERATURE_HYSTERESIS = 0.5
|
const val TEMPERATURE_HYSTERESIS = 0.5
|
||||||
const val HUMIDITY_HYSTERESIS = 2.0
|
const val HUMIDITY_HYSTERESIS = 2.0
|
||||||
const val TIME_TRIGGER_GRACE_MINUTES = 20L
|
const val TIME_TRIGGER_GRACE_SECONDS = 20L * 60L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user
更新竞态
场景写入和旧触发状态清理是多个独立 DAO 操作,前台轮询或 Worker 可在两者之间读取新场景和旧状态。编辑一个保留相同 ID 的阈值触发器时,引擎可能基于混合状态执行并写入新边沿,随后清理又将其删除,导致下次轮询重复执行或丢失边沿。应将读取、场景更新和触发状态清理放入同一个 Room 事务。