feat: confirm socket controls by readback

This commit is contained in:
2026-08-26 19:27:40 +08:00
parent 876acd1e7b
commit a30ab9e376
8 changed files with 121 additions and 13 deletions
@@ -133,6 +133,25 @@ class AbsdkBridge internal constructor(
}
}
override suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> = 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,
@@ -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<Unit> = 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,
@@ -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
@@ -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),
)
}
}
@@ -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())
@@ -63,6 +63,15 @@ interface SocketGateway {
deviceName: DeviceName,
): DomainResult<SocketStatus>
suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> = when (val result = setPower(profileId, deviceName, powerState)) {
is DomainResult.Success -> DomainResult.Success(Unit)
is DomainResult.Failure -> result
}
suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
@@ -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<Unit> {
states[DeviceKey(profileId, deviceName)] = powerState
return DomainResult.Success(Unit)
}
}
class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway {
@@ -18,6 +18,7 @@ import java.util.concurrent.CopyOnWriteArrayList
class FakeSocketGateway : SocketGateway {
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
private val errors = ConcurrentHashMap<DeviceKey, DomainError>()
private val commandErrors = ConcurrentHashMap<DeviceKey, DomainError>()
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<Unit> {
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,