fix(scene): preserve fixed gateway device identifiers, fix edit loading race condition and parallel abort strategy

This commit is contained in:
2026-08-25 18:00:25 +08:00
parent b48079564f
commit e7da708e37
9 changed files with 694 additions and 465 deletions
File diff suppressed because it is too large Load Diff
@@ -50,11 +50,12 @@ data class SceneEditorState(
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true, val enableLogging: Boolean = true,
val availableRemotes: List<VirtualRemote> = emptyList(), val availableRemotes: List<VirtualRemote> = emptyList(),
val isLoading: Boolean = false,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val isSaved: Boolean = false, val isSaved: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
) { ) {
val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty()) val isValid: Boolean get() = !isLoading && name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty())
val isEditing: Boolean get() = sceneId != null val isEditing: Boolean get() = sceneId != null
} }
@@ -100,14 +101,24 @@ class SceneEditorViewModel @Inject constructor(
type = initialType ?: AutomationType.MANUAL, type = initialType ?: AutomationType.MANUAL,
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5", colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
availableRemotes = it.availableRemotes, availableRemotes = it.availableRemotes,
isLoading = false,
) )
} }
return return
} }
_state.update {
it.copy(
sceneId = sceneId,
isLoading = true,
errorMessage = null,
)
}
viewModelScope.launch { viewModelScope.launch {
val profile = profileStore.activeProfile() val profile = profileStore.activeProfile()
if (profile == null) { if (profile == null) {
_state.update { it.copy(errorMessage = "No active profile") } _state.update { it.copy(isLoading = false, errorMessage = "No active profile") }
return@launch return@launch
} }
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
@@ -127,10 +138,11 @@ class SceneEditorViewModel @Inject constructor(
strategy = scene.strategy, strategy = scene.strategy,
concurrencyMode = scene.concurrencyMode, concurrencyMode = scene.concurrencyMode,
enableLogging = scene.enableLogging, enableLogging = scene.enableLogging,
isLoading = false,
) )
} }
} else { } else {
_state.update { it.copy(errorMessage = "Scene not found") } _state.update { it.copy(isLoading = false, errorMessage = "Scene not found") }
} }
} }
} }
@@ -196,8 +196,9 @@
<string name="scene_condition_desc_humidity">湿度需 %1$s %2$s%%</string> <string name="scene_condition_desc_humidity">湿度需 %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s 处于 %2$s</string> <string name="scene_condition_desc_socket">%1$s 处于 %2$s</string>
<string name="scene_condition_desc_time">时间在 %1$s ~ %2$s 之间</string> <string name="scene_condition_desc_time">时间在 %1$s ~ %2$s 之间</string>
<string name="scene_default_socket_name">Zigbee插座控制器</string> <string name="scene_device_name_label">设备名称(网关配对标识)</string>
<string name="scene_default_ir_name">红外控制器</string> <string name="scene_loading_scene">正在加载情景配置…</string>
<string name="scene_load_failed">无法加载情景配置。</string>
<string name="scene_default_button_power">电源</string> <string name="scene_default_button_power">电源</string>
<string name="scene_default_notify_title">情景完成</string> <string name="scene_default_notify_title">情景完成</string>
<string name="scene_default_notify_msg">自动化任务执行完成</string> <string name="scene_default_notify_msg">自动化任务执行完成</string>
+3 -2
View File
@@ -196,8 +196,9 @@
<string name="scene_condition_desc_humidity">Humidity must be %1$s %2$s%%</string> <string name="scene_condition_desc_humidity">Humidity must be %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s is %2$s</string> <string name="scene_condition_desc_socket">%1$s is %2$s</string>
<string name="scene_condition_desc_time">Time is between %1$s ~ %2$s</string> <string name="scene_condition_desc_time">Time is between %1$s ~ %2$s</string>
<string name="scene_default_socket_name">Zigbee Smart Socket</string> <string name="scene_device_name_label">Device Name (Gateway Identifier)</string>
<string name="scene_default_ir_name">Infrared Controller</string> <string name="scene_loading_scene">Loading scene...</string>
<string name="scene_load_failed">Failed to load scene.</string>
<string name="scene_default_button_power">Power</string> <string name="scene_default_button_power">Power</string>
<string name="scene_default_notify_title">Scene Completed</string> <string name="scene_default_notify_title">Scene Completed</string>
<string name="scene_default_notify_msg">Automation task executed successfully</string> <string name="scene_default_notify_msg">Automation task executed successfully</string>
@@ -81,10 +81,18 @@ class SceneEditorViewModelTest {
setupProfile() setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(existingScene.id) viewModel.initialize(existingScene.id)
// Immediately binds sceneId and enters loading state
assertTrue(viewModel.state.value.isEditing)
assertEquals(existingScene.id, viewModel.state.value.sceneId)
assertTrue(viewModel.state.value.isLoading)
assertFalse(viewModel.state.value.isValid)
advanceUntilIdle() advanceUntilIdle()
val state = viewModel.state.value val state = viewModel.state.value
assertTrue(state.isEditing) assertTrue(state.isEditing)
assertFalse(state.isLoading)
assertEquals("Existing Scene", state.name) assertEquals("Existing Scene", state.name)
assertEquals(1, state.actions.size) assertEquals(1, state.actions.size)
assertTrue(state.isValid) assertTrue(state.isValid)
@@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -36,15 +37,48 @@ class DefaultSceneExecutor(
return try { return try {
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
coroutineScope { if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
scene.actions.map { action -> coroutineScope {
async { lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
val actionStart = System.currentTimeMillis() deferreds = scene.actions.mapIndexed { index, action ->
val execResult = executeAction(scene.profileId, action) async {
val duration = System.currentTimeMillis() - actionStart val actionStart = System.currentTimeMillis()
execResult.copy(executionDurationMillis = duration) val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enriched = execResult.copy(executionDurationMillis = duration)
if (enriched.outcome == SceneActionOutcome.FAILURE) {
deferreds.forEachIndexed { otherIndex, otherDeferred ->
if (otherIndex != index) {
otherDeferred.cancel()
}
}
}
enriched
}
} }
}.awaitAll() deferreds.mapIndexed { index, deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
SceneActionExecutionResult(
action = scene.actions[index],
outcome = SceneActionOutcome.SKIPPED,
error = null,
)
}
}
}
} else {
coroutineScope {
scene.actions.map { action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
execResult.copy(executionDurationMillis = duration)
}
}.awaitAll()
}
} }
} else { } else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>() val sequentialResults = mutableListOf<SceneActionExecutionResult>()
@@ -308,5 +308,45 @@ class DefaultSceneExecutorTest {
) )
executor.execute(scene) executor.execute(scene)
} }
@Test
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.OperationFailed(detail = "Socket failed")),
)
val executor = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = TestInfraredGateway(),
delayDurationProvider = {
kotlinx.coroutines.delay(1000)
},
)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val action2 = SceneAction.Delay(
id = SceneActionId("a2"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("s-par-abort"),
profileId = profileId,
name = "Parallel Abort Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
strategy = ExecutionStrategy.ABORT_ON_ERROR,
actions = listOf(action1, action2),
)
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)
}
} }
@@ -22,6 +22,7 @@ import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.domain.SocketPowerState
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -227,15 +228,48 @@ class FakeSceneExecutor(
return try { return try {
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
coroutineScope { if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
scene.actions.map { action -> coroutineScope {
async { lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
val actionStart = System.currentTimeMillis() deferreds = scene.actions.mapIndexed { index, action ->
val execResult = executeAction(scene.profileId, action) async {
val duration = System.currentTimeMillis() - actionStart val actionStart = System.currentTimeMillis()
execResult.copy(executionDurationMillis = duration) val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enriched = execResult.copy(executionDurationMillis = duration)
if (enriched.outcome == SceneActionOutcome.FAILURE) {
deferreds.forEachIndexed { otherIndex, otherDeferred ->
if (otherIndex != index) {
otherDeferred.cancel()
}
}
}
enriched
}
} }
}.awaitAll() deferreds.mapIndexed { index, deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
SceneActionExecutionResult(
action = scene.actions[index],
outcome = SceneActionOutcome.SKIPPED,
error = null,
)
}
}
}
} else {
coroutineScope {
scene.actions.map { action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
execResult.copy(executionDurationMillis = duration)
}
}.awaitAll()
}
} }
} else { } else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>() val sequentialResults = mutableListOf<SceneActionExecutionResult>()
@@ -1,8 +1,10 @@
package com.flagship.abox.manager.testing.fakes package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneAction
@@ -156,6 +158,44 @@ class FakeSceneExecutorTest {
assertEquals(listOf(notificationAction), executor.sentNotifications) assertEquals(listOf(notificationAction), executor.sentNotifications)
} }
@Test
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
val socketGateway = FakeSocketGateway()
socketGateway.setError(
profileId,
DeviceName("socket-1"),
DomainError.DeviceOffline(detail = "offline"),
)
val executor = FakeSceneExecutor(
socketGateway = socketGateway,
infraredGateway = FakeInfraredGateway(),
delayDurationProvider = { kotlinx.coroutines.delay(1000) },
)
val action1 = socketAction("socket-1", SocketPowerState.ON)
val action2 = SceneAction.Delay(
id = SceneActionId("abox:delay-1"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("${profileId.value}:scene-par-abort"),
profileId = profileId,
name = "Parallel Abort Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
strategy = ExecutionStrategy.ABORT_ON_ERROR,
actions = listOf(action1, action2),
)
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)
}
private fun scene(actions: List<SceneAction>): Scene = Scene( private fun scene(actions: List<SceneAction>): Scene = Scene(
id = SceneId("${profileId.value}:scene-test"), id = SceneId("${profileId.value}:scene-test"),
profileId = profileId, profileId = profileId,