From a30ab9e3769a994d1b3047095d582b327ff8c9b9 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:27:40 +0800 Subject: [PATCH] feat: confirm socket controls by readback --- .../abox/manager/absdk/bridge/AbsdkBridge.kt | 19 ++++++++++++ .../manager/di/ProviderRoutingGateways.kt | 9 ++++++ .../abox/manager/ui/socket/SocketUiState.kt | 15 +++++++++- .../abox/manager/ui/socket/SocketViewModel.kt | 30 +++++++++++-------- .../manager/ui/socket/SocketViewModelTest.kt | 26 ++++++++++++++++ .../flagship/abox/manager/domain/Gateways.kt | 9 ++++++ .../simulation/SimulationDeviceGateways.kt | 9 ++++++ .../testing/fakes/FakeDeviceGateways.kt | 17 +++++++++++ 8 files changed, 121 insertions(+), 13 deletions(-) diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt index 9c26d29..324d2d5 100644 --- a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt @@ -133,6 +133,25 @@ class AbsdkBridge internal constructor( } } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = serialized { + withActiveSession(profileId) { + when (val control = call { + client.setSocketPower(deviceName.value, powerState.toSdkStatus()) + }) { + is ClientCall.Failed -> DomainResult.Failure(control.error) + is ClientCall.Completed -> if (control.response.isSuccess()) { + DomainResult.Success(Unit) + } else { + DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response)) + } + } + } + } + override suspend fun getReading( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt index b5576c0..b612ae5 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt @@ -62,6 +62,15 @@ internal class RoutingSocketGateway( is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName) } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).commandPower(profileId, deviceName, powerState) + } + override suspend fun setPower( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt index 2c47d33..fbb53cf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt @@ -14,14 +14,27 @@ data class SocketUiState( val isOffline: Boolean = false, val errorMessage: UiText? = null, val lastConfirmedTimeMillis: Long? = null, + val operation: SocketOperationState = SocketOperationState.IdleConfirmed, ) { val isActionEnabled: Boolean - get() = !isLoading && !isOperating && !isOffline && profileId != null && deviceName != null + get() = !isLoading && operation !is SocketOperationState.PendingCommand && + operation !is SocketOperationState.RefreshingConfirmation && + operation !is SocketOperationState.AwaitingConfirmation && + !isOffline && profileId != null && deviceName != null val isPowerOn: Boolean get() = powerState == SocketPowerState.ON } +sealed interface SocketOperationState { + data object IdleConfirmed : SocketOperationState + data class PendingCommand(val target: SocketPowerState) : SocketOperationState + data class RefreshingConfirmation(val target: SocketPowerState) : SocketOperationState + data class AwaitingConfirmation(val target: SocketPowerState) : SocketOperationState + data class Failed(val target: SocketPowerState) : SocketOperationState + data object Unavailable : SocketOperationState +} + sealed interface SocketUiEvent { data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt index 15364cc..264828e 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt @@ -56,6 +56,7 @@ class SocketViewModel @Inject constructor( it.copy( profileId = event.profileId, deviceName = event.deviceName, + operation = SocketOperationState.Unavailable, ) } loadStatus() @@ -115,6 +116,7 @@ class SocketViewModel @Inject constructor( isOffline = false, errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(), + operation = SocketOperationState.IdleConfirmed, ) } } @@ -126,6 +128,7 @@ class SocketViewModel @Inject constructor( isLoading = false, isOffline = isOffline, errorMessage = DomainErrorMapper.map(result.error), + operation = SocketOperationState.Unavailable, ) } } @@ -139,25 +142,27 @@ class SocketViewModel @Inject constructor( val deviceName = current.deviceName ?: return // Prevent duplicate action if currently operating, loading or offline - if (current.isOperating || current.isLoading || current.isOffline) { + if (current.isOperating || current.isLoading || current.isOffline || !current.isActionEnabled) { return } viewModelScope.launch { - _state.update { it.copy(isOperating = true, errorMessage = null) } - when (val result = socketGateway.setPower(profileId, deviceName, targetState)) { + _state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) } + when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) { is DomainResult.Success -> { - _state.update { - it.copy( - powerState = result.value.powerState, - isOperating = false, - isOffline = false, - errorMessage = null, - lastConfirmedTimeMillis = System.currentTimeMillis(), - ) + _state.update { it.copy(operation = SocketOperationState.RefreshingConfirmation(targetState)) } + when (val confirmation = socketGateway.getStatus(profileId, deviceName)) { + is DomainResult.Success -> _state.update { + it.copy(powerState = confirmation.value.powerState, isOperating = false, isOffline = false, + errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(), + operation = SocketOperationState.IdleConfirmed) + } + is DomainResult.Failure -> _state.update { + it.copy(isOperating = false, errorMessage = DomainErrorMapper.map(confirmation.error), + operation = SocketOperationState.AwaitingConfirmation(targetState)) + } } } - is DomainResult.Failure -> { val isOffline = result.error is DomainError.DeviceOffline _state.update { @@ -165,6 +170,7 @@ class SocketViewModel @Inject constructor( isOperating = false, isOffline = isOffline, errorMessage = DomainErrorMapper.map(result.error), + operation = SocketOperationState.Failed(targetState), ) } } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt index 9c43ba8..9aa680f 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt @@ -157,6 +157,32 @@ class SocketViewModelTest { assertNotNull(state.errorMessage) } + @Test + fun commandSuccessReadsBackConfirmedState() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + + viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) + + assertEquals(SocketPowerState.ON, viewModel.state.value.powerState) + assertEquals(SocketOperationState.IdleConfirmed, viewModel.state.value.operation) + } + + @Test + fun readbackFailureKeepsLastConfirmedStateAndMarksAwaitingConfirmation() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Readback failed")) + + viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) + + assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState) + assertEquals( + SocketOperationState.AwaitingConfirmation(SocketPowerState.ON), + viewModel.state.value.operation, + ) + } + @Test fun refresh_queriesUpdatedGatewayStatus() = runTest { val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt index 4108621..64a75aa 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt @@ -63,6 +63,15 @@ interface SocketGateway { deviceName: DeviceName, ): DomainResult + suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = when (val result = setPower(profileId, deviceName, powerState)) { + is DomainResult.Success -> DomainResult.Success(Unit) + is DomainResult.Failure -> result + } + suspend fun setPower( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt index c45d265..9daedf5 100644 --- a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt @@ -33,6 +33,15 @@ class SimulationSocketGateway : SocketGateway { states[key] = powerState return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) } + + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + states[DeviceKey(profileId, deviceName)] = powerState + return DomainResult.Success(Unit) + } } class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway { diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt index 60c3c21..27b6a55 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt @@ -18,6 +18,7 @@ import java.util.concurrent.CopyOnWriteArrayList class FakeSocketGateway : SocketGateway { private val states = ConcurrentHashMap() private val errors = ConcurrentHashMap() + private val commandErrors = ConcurrentHashMap() override suspend fun getStatus( profileId: ProviderProfileId, @@ -35,11 +36,27 @@ class FakeSocketGateway : SocketGateway { return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + val key = DeviceKey(profileId, deviceName) + commandErrors[key]?.let { return DomainResult.Failure(it) } + states[key] = powerState + return DomainResult.Success(Unit) + } + fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) { val key = DeviceKey(profileId, deviceName) if (error == null) errors.remove(key) else errors[key] = error } + fun setCommandError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) { + val key = DeviceKey(profileId, deviceName) + if (error == null) commandErrors.remove(key) else commandErrors[key] = error + } + private fun resultFor( profileId: ProviderProfileId, deviceName: DeviceName,