diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt new file mode 100644 index 0000000..941bd73 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt @@ -0,0 +1,151 @@ +package com.flagship.abox.manager.domain + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +interface AutomationClock { + fun nowEpochMillis(): Long +} + +data class AutomationSnapshot( + val observedAtEpochMillis: Long, + val readings: Map> = emptyMap(), + val socketStatuses: Map> = emptyMap(), +) + +sealed interface TriggerEvaluation { + data class Matched(val occurrenceKey: String) : TriggerEvaluation + data object NotMatched : TriggerEvaluation + data class Unavailable(val error: DomainError) : TriggerEvaluation +} + +sealed interface ConditionsEvaluation { + data object Satisfied : ConditionsEvaluation + data class NotSatisfied(val condition: SceneCondition) : ConditionsEvaluation + data class Unavailable(val error: DomainError) : ConditionsEvaluation +} + +class SceneTriggerEvaluator { + fun evaluate(scene: Scene, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation { + if (scene.triggers.isEmpty()) return TriggerEvaluation.NotMatched + val matched = scene.triggers.firstNotNullOfOrNull { trigger -> evaluateOne(trigger, snapshot, now) } + return when (matched) { + is TriggerEvaluation.Matched -> matched + is TriggerEvaluation.Unavailable -> matched + is TriggerEvaluation.NotMatched -> matched + null -> 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) { + TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}") + } else null + } + is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) { + it.temperature + } + is SceneTrigger.Humidity -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdPercent) { + it.humidity + } + is SceneTrigger.DeviceState -> { + when (val result = snapshot.socketStatuses[trigger.deviceName]) { + null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable")) + is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error) + is DomainResult.Success -> if (result.value.powerState == trigger.expectedState) { + TriggerEvaluation.Matched("${snapshot.observedAtEpochMillis}:${trigger.id.value}") + } else null + } + } + } + + private fun threshold( + result: DomainResult?, + operator: ComparisonOperator, + threshold: Double, + value: (TemperatureHumidityReading) -> String?, + ): TriggerEvaluation? { + if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold")) + return when (result) { + null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error) + 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 + } + } + } + + private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) { + ComparisonOperator.GREATER_THAN -> actual > threshold + ComparisonOperator.LESS_THAN -> actual < threshold + ComparisonOperator.EQUALS -> actual == threshold + ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold + ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + } + + private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull() +} + +class SceneConditionEvaluator { + fun evaluate(conditions: List, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation { + for (condition in conditions) { + when (val result = evaluateOne(condition, snapshot, now)) { + ConditionsEvaluation.Satisfied -> Unit + else -> return result + } + } + return ConditionsEvaluation.Satisfied + } + + private fun evaluateOne(condition: SceneCondition, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation = when (condition) { + is SceneCondition.Temperature -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdCelsius) { it.temperature } + is SceneCondition.Humidity -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdPercent) { it.humidity } + is SceneCondition.SocketPower -> when (val result = snapshot.socketStatuses[condition.deviceName]) { + null -> ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable")) + is DomainResult.Failure -> ConditionsEvaluation.Unavailable(result.error) + is DomainResult.Success -> if (result.value.powerState == condition.expectedState) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + is SceneCondition.TimeRange -> { + val start = parseTime(condition.startTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range start")) + val end = parseTime(condition.endTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range end")) + val current = now.toLocalTime() + val inside = if (start <= end) current in start..end else current >= start || current <= end + if (inside) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + } + + private fun metricCondition( + condition: SceneCondition, + result: DomainResult?, + operator: ComparisonOperator, + threshold: Double, + value: (TemperatureHumidityReading) -> String?, + ): ConditionsEvaluation { + if (!threshold.isFinite()) return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold")) + val actual = when (result) { + null -> return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + is DomainResult.Failure -> return ConditionsEvaluation.Unavailable(result.error) + is DomainResult.Success -> value(result.value)?.toDoubleOrNull() + ?: return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + } + return if (compare(actual, operator, threshold)) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + + private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) { + ComparisonOperator.GREATER_THAN -> actual > threshold + ComparisonOperator.LESS_THAN -> actual < threshold + ComparisonOperator.EQUALS -> actual == threshold + ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold + ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + } + + private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull() +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt index aa97602..7a71edf 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt @@ -62,7 +62,7 @@ sealed interface SceneTrigger { data class DeviceState( override val id: SceneTriggerId, val deviceName: DeviceName, - val expectedState: String, // e.g. "ON" / "OFF" + val expectedState: SocketPowerState, val label: String = "", ) : SceneTrigger } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt new file mode 100644 index 0000000..8980977 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt @@ -0,0 +1,80 @@ +package com.flagship.abox.manager.domain.automation + +import com.flagship.abox.manager.domain.AutomationSnapshot +import com.flagship.abox.manager.domain.ComparisonOperator +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.SceneId +import com.flagship.abox.manager.domain.SceneTrigger +import com.flagship.abox.manager.domain.SceneTriggerEvaluator +import com.flagship.abox.manager.domain.SceneTriggerId +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import com.flagship.abox.manager.domain.TriggerEvaluation +import java.time.LocalDateTime +import org.junit.Assert.assertTrue +import org.junit.Test + +class SceneEvaluatorTest { + private val profile = ProviderProfileId("profile") + private val sensor = DeviceName("sensor") + private val clockTime = LocalDateTime.of(2026, 8, 26, 7, 30) + private val triggerEvaluator = SceneTriggerEvaluator() + private val conditionEvaluator = SceneConditionEvaluator() + + @Test + fun timeMatchesConfiguredWeekday() { + val trigger = SceneTrigger.Time(SceneTriggerId("time"), "07:30", setOf(3)) + val result = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime) + assertTrue(result is TriggerEvaluation.Matched) + } + + @Test + fun timeRangeCrossesMidnight() { + val condition = SceneCondition.TimeRange(com.flagship.abox.manager.domain.SceneConditionId("range"), "22:00", "07:00") + val result = conditionEvaluator.evaluate(listOf(condition), AutomationSnapshot(1L), LocalDateTime.of(2026, 8, 26, 6, 45)) + assertTrue(result is ConditionsEvaluation.Satisfied) + } + + @Test + fun temperatureComparisonAndMissingReadingFailClosed() { + val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0) + val missing = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime) + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", null))), + ) + val matched = triggerEvaluator.evaluate(scene(trigger), reading, clockTime) + assertTrue(missing is TriggerEvaluation.Unavailable) + assertTrue(matched is TriggerEvaluation.Matched) + } + + @Test + fun allConditionsMustPass() { + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))), + ) + val conditions = listOf( + SceneCondition.Temperature(com.flagship.abox.manager.domain.SceneConditionId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0), + SceneCondition.Humidity(com.flagship.abox.manager.domain.SceneConditionId("humidity"), sensor, ComparisonOperator.LESS_THAN, 50.0), + ) + assertTrue(conditionEvaluator.evaluate(conditions, reading, clockTime) is ConditionsEvaluation.Satisfied) + } + + @Test + fun invalidNumberReturnsUnavailable() { + val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, Double.NaN) + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))), + ) + assertTrue(triggerEvaluator.evaluate(scene(trigger), reading, clockTime) is TriggerEvaluation.Unavailable) + } + + private fun scene(trigger: SceneTrigger) = Scene(SceneId("scene"), profile, "测试", triggers = listOf(trigger)) +}