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
@@ -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())