feat(scene): complete automation runtime (#11)
## Summary - persist scenes and trigger state in Room with a database migration and one-time default seeding - execute enabled automatic scenes from real device readings with edge-triggered thresholds, conditions, hysteresis, and time occurrence deduplication - schedule foreground polling and WorkManager background evaluation, including reconciliation after scene changes - allow automatic commands to be run manually for testing while bypassing triggers and conditions - send real system notifications with runtime permission handling and accurate failure reporting - move Other Options & Appearance to the bottom of the command editor ## Verification - ./gradlew.bat :android:app:assembleDebug --no-daemon - installed and exercised on mulator-5554 - verified manual automatic-command execution and Activity records - verified notification permission flow, posted notification, and successful Activity records Automated tests were not run per the requested iteration workflow. Reviewed-on: #11 Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #11.
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,13 +41,40 @@ 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) {
|
||||
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
|
||||
} else null
|
||||
val scheduled = listOf(now.toLocalDate(), now.toLocalDate().minusDays(1))
|
||||
.map { date -> LocalDateTime.of(date, time) }
|
||||
.firstOrNull { candidate ->
|
||||
val dayMatches = trigger.daysOfWeek.isEmpty() || candidate.dayOfWeek.value in trigger.daysOfWeek
|
||||
val latenessSeconds = Duration.between(candidate, now).seconds
|
||||
dayMatches && latenessSeconds in 0..TIME_TRIGGER_GRACE_SECONDS
|
||||
}
|
||||
scheduled?.let { occurrence ->
|
||||
TriggerEvaluation.Matched("${occurrence.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
|
||||
}
|
||||
}
|
||||
is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) {
|
||||
it.temperature
|
||||
@@ -69,6 +97,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 +107,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 +120,26 @@ 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
|
||||
const val TIME_TRIGGER_GRACE_SECONDS = 20L * 60L
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@@ -173,7 +174,23 @@ class SessionCoordinator(
|
||||
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
|
||||
val profile = profileStore.activeProfile()
|
||||
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
|
||||
profile.gateway().validateSession(profile.id)
|
||||
validateSession(profile.id)
|
||||
}
|
||||
|
||||
suspend fun validateSession(profileId: ProviderProfileId): DomainResult<SessionValidity> = storeOperation {
|
||||
val profile = profileStore.activeProfile()
|
||||
if (profile?.id != profileId) return@storeOperation DomainResult.Success(SessionValidity.INVALID)
|
||||
val session = profile.gateway().observeSession(profileId).first()
|
||||
if (session?.profileId != profileId) return@storeOperation DomainResult.Success(SessionValidity.INVALID)
|
||||
when (val validity = profile.gateway().validateSession(profileId)) {
|
||||
is DomainResult.Failure -> validity
|
||||
is DomainResult.Success -> {
|
||||
val stillActive = profileStore.activeProfile()?.id == profileId
|
||||
val currentSession = profile.gateway().observeSession(profileId).first()
|
||||
if (stillActive && currentSession?.profileId == profileId) validity
|
||||
else DomainResult.Success(SessionValidity.INVALID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout(): DomainResult<Unit> = storeOperation {
|
||||
|
||||
Reference in New Issue
Block a user