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:
2026-08-28 06:39:31 +08:00
committed by KremeCN
parent 86b7e5849b
commit 4d4dd2893e
30 changed files with 1141 additions and 157 deletions
@@ -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 {