feat(ui): complete ABox app experience redesign #10
@@ -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<DeviceName, DomainResult<TemperatureHumidityReading>> = emptyMap(),
|
||||
val socketStatuses: Map<DeviceName, DomainResult<SocketStatus>> = 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<TemperatureHumidityReading>?,
|
||||
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<SceneCondition>, 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<TemperatureHumidityReading>?,
|
||||
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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+80
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user