feat(scene): complete automation runtime

This commit is contained in:
2026-08-28 01:29:48 +08:00
parent 86b7e5849b
commit 8e824b50da
28 changed files with 969 additions and 147 deletions
@@ -5,6 +5,7 @@ import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.Duration
interface AutomationClock {
fun nowEpochMillis(): Long
@@ -40,11 +41,34 @@ class SceneTriggerEvaluator {
}
}
fun evaluate(
trigger: SceneTrigger,
snapshot: AutomationSnapshot,
now: LocalDateTime,
wasMatched: Boolean = false,
): TriggerEvaluation = when (trigger) {
is SceneTrigger.Temperature -> threshold(
snapshot.readings[trigger.deviceName],
trigger.operator,
trigger.thresholdCelsius,
hysteresis = if (wasMatched) TEMPERATURE_HYSTERESIS else 0.0,
) { it.temperature } ?: TriggerEvaluation.NotMatched
is SceneTrigger.Humidity -> threshold(
snapshot.readings[trigger.deviceName],
trigger.operator,
trigger.thresholdPercent,
hysteresis = if (wasMatched) HUMIDITY_HYSTERESIS else 0.0,
) { it.humidity } ?: TriggerEvaluation.NotMatched
else -> evaluateOne(trigger, snapshot, now) ?: TriggerEvaluation.NotMatched
}
private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) {
is SceneTrigger.Time -> {
val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time"))
val dayMatches = trigger.daysOfWeek.isEmpty() || now.dayOfWeek.value in trigger.daysOfWeek
if (dayMatches && now.toLocalTime().hour == time.hour && now.toLocalTime().minute == time.minute) {
val scheduled = LocalDateTime.of(now.toLocalDate(), time)
val lateness = Duration.between(scheduled, now).toMinutes()
if (dayMatches && lateness in 0..20) {
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
} else null
}
@@ -69,6 +93,7 @@ class SceneTriggerEvaluator {
result: DomainResult<TemperatureHumidityReading>?,
operator: ComparisonOperator,
threshold: Double,
hysteresis: Double = 0.0,
value: (TemperatureHumidityReading) -> String?,
): TriggerEvaluation? {
if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
@@ -78,7 +103,7 @@ class SceneTriggerEvaluator {
is DomainResult.Success -> {
val actual = value(result.value)?.toDoubleOrNull()
?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
if (compare(actual, operator, threshold)) TriggerEvaluation.Matched("threshold") else null
if (compareWithHysteresis(actual, operator, threshold, hysteresis)) TriggerEvaluation.Matched("threshold") else null
}
}
}
@@ -91,7 +116,25 @@ class SceneTriggerEvaluator {
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
}
private fun compareWithHysteresis(
actual: Double,
operator: ComparisonOperator,
threshold: Double,
hysteresis: Double,
): Boolean = when (operator) {
ComparisonOperator.GREATER_THAN -> actual > threshold - hysteresis
ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold - hysteresis
ComparisonOperator.LESS_THAN -> actual < threshold + hysteresis
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + hysteresis
ComparisonOperator.EQUALS -> kotlin.math.abs(actual - threshold) <= maxOf(0.1, hysteresis)
}
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
private companion object {
const val TEMPERATURE_HYSTERESIS = 0.5
const val HUMIDITY_HYSTERESIS = 2.0
}
}
class SceneConditionEvaluator {
@@ -22,6 +22,7 @@ class DefaultSceneExecutor(
private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway,
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
private val notificationGateway: NotificationGateway? = null,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor {
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
@@ -222,11 +223,15 @@ class DefaultSceneExecutor(
}
is SceneAction.Notification -> {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
if (notificationGateway == null) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = "Notification gateway is unavailable"),
)
} else {
toExecutionResult(action, notificationGateway.show(profileId, action.title, action.message))
}
}
}
} catch (e: CancellationException) {