feat(scene): refactor scene automation into modular trigger-action system

- Support manual one-tap commands and automatic condition-based rules
- Add modular triggers (time, temperature, humidity, device state)
- Add modular conditions (environment thresholds, socket state, time range)
- Upgrade actions to support virtual remotes, sockets, delays, and notifications
- Support sequential/parallel concurrency mode and error execution strategies
- Refactor SceneListScreen with filter tabs, quick execution, and enable switches
- Refactor SceneEditorScreen with modular builders and preset pickers
- Enhance SceneExecutionScreen with step timing, skips, and rerun capability
- Add comprehensive domain and UI ViewModel unit tests
This commit is contained in:
2026-08-25 16:15:31 +08:00
parent f70f034119
commit 9ac1616046
18 changed files with 2345 additions and 410 deletions
@@ -1,20 +1,21 @@
package com.flagship.abox.manager.domain
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
/**
* Production implementation of [SceneExecutor].
*
* Executes scene actions sequentially via [SocketGateway] and [InfraredGateway].
* Guarantees:
* 1. Actions are run sequentially in the order of [Scene.actions].
* 2. Individual action failures do not abort remaining actions; all actions run to completion.
* 3. Results are aggregated into a [SceneExecutionResult].
* 4. Re-entrant / concurrent execution of the same scene is rejected with [DomainError.OperationFailed].
* Executes scene actions via [SocketGateway], [InfraredGateway], [SendVirtualRemoteAction],
* and built-in timers.
*/
class DefaultSceneExecutor(
private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway,
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor {
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
@@ -27,9 +28,41 @@ class DefaultSceneExecutor(
),
)
}
val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
var aborted = false
return try {
val results = scene.actions.map { action -> executeAction(scene.profileId, action) }
DomainResult.Success(SceneExecutionResult(scene = scene, results = results))
for (action in scene.actions) {
if (aborted) {
results.add(
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SKIPPED,
error = null,
),
)
continue
}
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enrichedResult = execResult.copy(executionDurationMillis = duration)
results.add(enrichedResult)
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
aborted = true
}
}
val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success(
SceneExecutionResult(
scene = scene,
results = results,
totalDurationMillis = totalDuration,
),
)
} finally {
runningScenes.remove(scene.id)
}
@@ -39,30 +72,80 @@ class DefaultSceneExecutor(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
val result = when (action) {
is SceneAction.SocketPower -> socketGateway.setPower(
profileId = profileId,
deviceName = action.deviceName,
powerState = action.powerState,
)
return when (action) {
is SceneAction.SocketPower -> {
val res = socketGateway.setPower(
profileId = profileId,
deviceName = action.deviceName,
powerState = action.powerState,
)
toExecutionResult(action, res)
}
is SceneAction.InfraredKey -> infraredGateway.sendKey(
profileId = profileId,
deviceName = action.deviceName,
key = action.key,
)
is SceneAction.InfraredKey -> {
val res = infraredGateway.sendKey(
profileId = profileId,
deviceName = action.deviceName,
key = action.key,
)
toExecutionResult(action, res)
}
is SceneAction.VirtualRemoteButton -> {
if (sendVirtualRemoteAction != null) {
val res = sendVirtualRemoteAction.sendButton(
profileId = profileId,
remoteId = VirtualRemoteId(action.remoteId),
buttonId = action.buttonId,
)
toExecutionResult(action, res)
} else {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
}
is SceneAction.Delay -> {
try {
delayDurationProvider(action.durationSeconds * 1000L)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
}
is SceneAction.Notification -> {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
}
return when (result) {
}
private fun toExecutionResult(action: SceneAction, res: DomainResult<*>): SceneActionExecutionResult {
return when (res) {
is DomainResult.Success -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
is DomainResult.Failure -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = result.error,
error = res.error,
)
}
}
@@ -96,36 +96,63 @@ class InMemorySceneRepository : SceneRepository {
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "Leaving home",
name = "离家模式",
description = "关闭插座并延时关闭电视",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.AWAY,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.OFF,
),
SceneAction.Delay(
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
profileId = profileId,
durationSeconds = 1,
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
deviceName = DeviceName("红外控制器"),
key = "1",
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-movie-night"),
id = SceneId("${profileId.value}:scene-cooling-auto"),
profileId = profileId,
name = "Movie night",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
name = "高温自动降温",
description = "当室温高于 28°C 时开启插座",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.HOME,
color = SceneColor("#26A69A"),
triggers = listOf(
SceneTrigger.Temperature(
id = SceneTriggerId("${profileId.value}:trigger-temp"),
deviceName = DeviceName("温湿度传感器"),
operator = ComparisonOperator.GREATER_THAN,
thresholdCelsius = 28.0,
label = "温度高于 28°C",
),
),
conditions = listOf(
SceneCondition.SocketPower(
id = SceneConditionId("${profileId.value}:cond-socket"),
deviceName = DeviceName("Zigbee插座控制器"),
expectedState = SocketPowerState.OFF,
),
),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"),
id = SceneActionId("${profileId.value}:scene-cooling:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.ON,
),
),
),
@@ -6,51 +6,164 @@ value class SceneId(val value: String)
@JvmInline
value class SceneActionId(val value: String)
@JvmInline
value class SceneTriggerId(val value: String)
@JvmInline
value class SceneConditionId(val value: String)
enum class AutomationType {
MANUAL, // 一键指令 (One-Tap / Instant Command)
AUTOMATIC, // 自动指令 (Trigger & Condition Based Automation)
}
enum class SceneActionType {
SOCKET_POWER,
VIRTUAL_REMOTE_BUTTON,
INFRARED_KEY,
DELAY,
NOTIFICATION,
}
enum class ComparisonOperator(val symbol: String) {
GREATER_THAN(">"),
LESS_THAN("<"),
EQUALS("="),
GREATER_OR_EQUAL(">="),
LESS_OR_EQUAL("<="),
}
sealed interface SceneTrigger {
val id: SceneTriggerId
data class Time(
override val id: SceneTriggerId,
val timeOfDay: String, // e.g. "08:30"
val daysOfWeek: Set<Int> = emptySet(), // 1..7 (Monday..Sunday), empty means everyday
val label: String = "",
) : SceneTrigger
data class Temperature(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdCelsius: Double,
val label: String = "",
) : SceneTrigger
data class Humidity(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdPercent: Double,
val label: String = "",
) : SceneTrigger
data class DeviceState(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val expectedState: String, // e.g. "ON" / "OFF"
val label: String = "",
) : SceneTrigger
}
sealed interface SceneCondition {
val id: SceneConditionId
data class Temperature(
override val id: SceneConditionId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdCelsius: Double,
) : SceneCondition
data class Humidity(
override val id: SceneConditionId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdPercent: Double,
) : SceneCondition
data class SocketPower(
override val id: SceneConditionId,
val deviceName: DeviceName,
val expectedState: SocketPowerState,
) : SceneCondition
data class TimeRange(
override val id: SceneConditionId,
val startTime: String, // "22:00"
val endTime: String, // "07:00"
) : SceneCondition
}
/**
* A single executable step inside a [Scene].
*
* Socket actions reference the [DeviceName] accepted by ABSDK 0630 and a target [SocketPowerState].
* Infrared actions reference a [DeviceName] and the stable key string provided by the infrared
* module; the executor delegates the actual send to [InfraredGateway.sendKey].
*
* `DeviceName` is used (rather than `DeviceId`) because ABSDK addresses devices by name, matching
* the existing [SocketGateway] and [InfraredGateway] contracts. The infrared module is responsible
* for keeping the key string stable across edits, so scenes can reference it without coupling to
* the infrared storage primary key.
*/
sealed interface SceneAction {
val id: SceneActionId
val profileId: ProviderProfileId
val deviceName: DeviceName
val type: SceneActionType
data class SocketPower(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
override val deviceName: DeviceName,
val deviceName: DeviceName,
val powerState: SocketPowerState,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.SOCKET_POWER
}
data class VirtualRemoteButton(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val remoteId: String,
val remoteDisplayName: String,
val buttonId: String,
val buttonDisplayName: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.VIRTUAL_REMOTE_BUTTON
}
data class InfraredKey(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
override val deviceName: DeviceName,
val deviceName: DeviceName,
val key: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.INFRARED_KEY
}
data class Delay(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val durationSeconds: Int,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.DELAY
}
data class Notification(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val title: String,
val message: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.NOTIFICATION
}
}
enum class ExecutionStrategy {
CONTINUE_ON_ERROR, // 遇错继续
ABORT_ON_ERROR, // 遇错中止
}
enum class ConcurrencyMode {
SEQUENTIAL, // 同步 / 串行
PARALLEL, // 异步 / 并行
}
/**
* Visual identity for a scene. Kept as a small enum so the UI can resolve the icon without
* external resources and so the persistence layer stays stable across icon set changes.
* Visual identity for a scene.
*/
enum class SceneIcon {
HOME,
@@ -58,12 +171,17 @@ enum class SceneIcon {
NIGHT,
MOVIE,
AWAY,
READING,
DINING,
GAMING,
THERMOSTAT,
LIGHT,
CLEAN,
CUSTOM,
}
/**
* Scene accent color stored as an uppercase hex string (e.g. `#FF8A65`). The UI maps it to the
* Compose color space; persistence stores it verbatim.
* Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`).
*/
@JvmInline
value class SceneColor(val hex: String)
@@ -72,7 +190,15 @@ data class Scene(
val id: SceneId,
val profileId: ProviderProfileId,
val name: String,
val icon: SceneIcon,
val color: SceneColor,
val actions: List<SceneAction>,
val description: String = "",
val type: AutomationType = AutomationType.MANUAL,
val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME,
val color: SceneColor = SceneColor("#3F51B5"),
val triggers: List<SceneTrigger> = emptyList(),
val conditions: List<SceneCondition> = emptyList(),
val actions: List<SceneAction> = emptyList(),
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true,
)
@@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain
enum class SceneActionOutcome {
SUCCESS,
FAILURE,
SKIPPED,
}
/**
@@ -12,21 +13,24 @@ data class SceneActionExecutionResult(
val action: SceneAction,
val outcome: SceneActionOutcome,
val error: DomainError?,
val executionDurationMillis: Long = 0L,
)
/**
* Aggregated result of executing a [Scene]. Actions are executed sequentially in the order they
* appear in [scene.actions]; a per-action failure does not abort subsequent actions.
* Aggregated result of executing a [Scene].
*/
data class SceneExecutionResult(
val scene: Scene,
val results: List<SceneActionExecutionResult>,
val totalDurationMillis: Long = 0L,
) {
val totalActions: Int get() = results.size
val succeededCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SUCCESS }
val failedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.FAILURE }
val skippedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SKIPPED }
val allSucceeded: Boolean
get() = results.isNotEmpty() && failedCount == 0
get() = results.isNotEmpty() && failedCount == 0 && skippedCount == 0
}
@@ -149,4 +149,77 @@ class DefaultSceneExecutorTest {
assertEquals(1, socketGateway.calls.size)
assertEquals(1, infraredGateway.calls.size)
}
@Test
fun abortsExecutionOnFailureWhenStrategyIsAbortOnError() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
)
val infraredGateway = TestInfraredGateway(
response = DomainResult.Success(Unit),
)
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "vol_up",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(action1, action2),
strategy = ExecutionStrategy.ABORT_ON_ERROR,
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
assertEquals(0, infraredGateway.calls.size)
}
@Test
fun executesDelayActionSuccessfully() = runTest {
var delayCalled = 0L
val executor = DefaultSceneExecutor(
socketGateway = TestSocketGateway(),
infraredGateway = TestInfraredGateway(),
delayDurationProvider = { delayCalled = it },
)
val action = SceneAction.Delay(
id = SceneActionId("d1"),
profileId = profileId,
durationSeconds = 5,
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Delay Scene",
actions = listOf(action),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(1, executionResult.results.size)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
assertEquals(5000L, delayCalled)
}
}