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(
|
data class DeviceState(
|
||||||
override val id: SceneTriggerId,
|
override val id: SceneTriggerId,
|
||||||
val deviceName: DeviceName,
|
val deviceName: DeviceName,
|
||||||
val expectedState: String, // e.g. "ON" / "OFF"
|
val expectedState: SocketPowerState,
|
||||||
val label: String = "",
|
val label: String = "",
|
||||||
) : SceneTrigger
|
) : 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