feat(scene): complete automation runtime
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user